fabricjs-document-engine 0.1.0 → 0.2.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 (54) hide show
  1. package/README.md +87 -10
  2. package/dist/document/document-format.d.cts +1 -0
  3. package/dist/document/document-format.d.ts +1 -0
  4. package/dist/document/validate-document.cjs +1 -0
  5. package/dist/document/validate-document.js +1 -0
  6. package/dist/engine/create-document-engine.cjs +27 -14
  7. package/dist/engine/create-document-engine.d.cts +11 -5
  8. package/dist/engine/create-document-engine.d.ts +11 -5
  9. package/dist/engine/create-document-engine.js +27 -14
  10. package/dist/engine/errors.cjs +5 -0
  11. package/dist/engine/errors.d.cts +4 -1
  12. package/dist/engine/errors.d.ts +4 -1
  13. package/dist/engine/errors.js +5 -1
  14. package/dist/history/create-history.cjs +3 -1
  15. package/dist/history/create-history.js +3 -1
  16. package/dist/index.cjs +3 -0
  17. package/dist/index.d.cts +8 -3
  18. package/dist/index.d.ts +8 -3
  19. package/dist/index.js +3 -2
  20. package/dist/save/autosave-scheduler.cjs +28 -0
  21. package/dist/save/autosave-scheduler.d.cts +6 -0
  22. package/dist/save/autosave-scheduler.d.ts +6 -0
  23. package/dist/save/autosave-scheduler.js +28 -0
  24. package/dist/save/retry.cjs +33 -0
  25. package/dist/save/retry.d.cts +7 -0
  26. package/dist/save/retry.d.ts +7 -0
  27. package/dist/save/retry.js +31 -0
  28. package/dist/save/save-controller.cjs +148 -0
  29. package/dist/save/save-controller.d.cts +20 -0
  30. package/dist/save/save-controller.d.ts +20 -0
  31. package/dist/save/save-controller.js +148 -0
  32. package/dist/save/unsaved-changes-warning.cjs +12 -0
  33. package/dist/save/unsaved-changes-warning.d.cts +6 -0
  34. package/dist/save/unsaved-changes-warning.d.ts +6 -0
  35. package/dist/save/unsaved-changes-warning.js +12 -0
  36. package/dist/storage/key-value-storage.cjs +35 -0
  37. package/dist/storage/key-value-storage.d.cts +14 -0
  38. package/dist/storage/key-value-storage.d.ts +14 -0
  39. package/dist/storage/key-value-storage.js +35 -0
  40. package/dist/storage/local-storage.cjs +24 -0
  41. package/dist/storage/local-storage.d.cts +8 -0
  42. package/dist/storage/local-storage.d.ts +8 -0
  43. package/dist/storage/local-storage.js +24 -0
  44. package/dist/storage/memory-storage.cjs +17 -0
  45. package/dist/storage/memory-storage.d.cts +4 -0
  46. package/dist/storage/memory-storage.d.ts +4 -0
  47. package/dist/storage/memory-storage.js +17 -0
  48. package/dist/storage/storage-contract.d.cts +14 -0
  49. package/dist/storage/storage-contract.d.ts +14 -0
  50. package/dist/storage.cjs +7 -0
  51. package/dist/storage.d.cts +5 -0
  52. package/dist/storage.d.ts +5 -0
  53. package/dist/storage.js +4 -0
  54. package/package.json +14 -3
package/README.md CHANGED
@@ -8,7 +8,8 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
8
8
  - **A versioned document format.** It records the schema version, canvas size, background, object order and your own metadata.
9
9
  - **Safe loading.** Documents are validated first. Unknown object types are refused before the canvas is touched. A missing image fails the load instead of silently disappearing. When loads overlap, the newest one wins.
10
10
  - **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
11
- - **Your storage.** Plug in any backend with two functions. No hosted service is needed.
11
+ - **Safe saving.** It tracks unsaved changes and can autosave. Only one save runs at a time, so a slow older save can never overwrite newer work. Revision checks catch another tab or device saving the same document, and failed saves are retried with backoff.
12
+ - **Your storage.** Plug in any backend with two functions, or use the built-in memory and localStorage adapters. No hosted service is needed.
12
13
  - **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.
13
14
  - **Fabric 6 and 7.** Every release is tested against both.
14
15
 
@@ -37,20 +38,89 @@ await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
37
38
 
38
39
  ## Saving and loading through storage
39
40
 
41
+ The built-in adapters are the quickest way to start:
42
+
40
43
  ```ts
44
+ import { createLocalStorage, createMemoryStorage } from 'fabricjs-document-engine/storage';
45
+
41
46
  const engine = createDocumentEngine({
42
47
  canvas,
43
- storage: {
44
- loadDocument: (id) => fetch(`/api/documents/${id}`).then((response) => response.json()),
45
- saveDocument: (document) =>
46
- fetch(`/api/documents/${document.id}`, { method: 'PUT', body: JSON.stringify(document) }).then(() => {}),
47
- },
48
+ storage: createLocalStorage({ prefix: 'my-app:' }),
49
+ autosave: true,
48
50
  });
49
51
 
50
52
  await engine.load('project-42');
51
53
  await engine.save();
52
54
  ```
53
55
 
56
+ Both adapters also have `listDocuments()` and `deleteDocument(id)`. To build on another key-value store, use `createKeyValueStorage({ read, write, remove, keys }, prefix)`.
57
+
58
+ ### Your own backend
59
+
60
+ A storage adapter is two functions:
61
+
62
+ ```ts
63
+ import { createDocumentEngine, createConflictError } from 'fabricjs-document-engine';
64
+ import type { DocumentStorage } from 'fabricjs-document-engine';
65
+
66
+ const storage: DocumentStorage = {
67
+ async loadDocument(id) {
68
+ const response = await fetch(`/api/documents/${id}`);
69
+ return response.json();
70
+ },
71
+ async saveDocument(document, { expectedRevision, signal }) {
72
+ const response = await fetch(`/api/documents/${document.id}`, {
73
+ method: 'PUT',
74
+ headers: { 'If-Match': String(expectedRevision ?? '*') },
75
+ body: JSON.stringify(document),
76
+ signal,
77
+ });
78
+ if (response.status === 409) throw Object.assign(new Error('Saved elsewhere'), { code: 'SAVE_CONFLICT' });
79
+ if (response.status === 403) throw Object.assign(new Error('Not allowed'), { retryable: false });
80
+ if (!response.ok) throw new Error(`Save failed with ${response.status}`);
81
+ return { revision: document.revision };
82
+ },
83
+ };
84
+ ```
85
+
86
+ - `expectedRevision` is the revision this editor last saved or loaded. Reject the save when the stored document has a different revision. It is `null` when the user chose to overwrite.
87
+ - `document.revision` is the next revision. If your backend assigns its own number, return `{ revision }`.
88
+ - Throw an error with `code: 'SAVE_CONFLICT'`, or use `createConflictError(id, expected, actual)`, to report a conflict. Conflicts are never retried.
89
+ - Throw an error with `retryable: false` for failures that retrying cannot fix. Every other error is retried.
90
+ - Pass `signal` to `fetch`. The engine aborts it when another document is opened.
91
+
92
+ ## Safe saving
93
+
94
+ ```ts
95
+ const engine = createDocumentEngine({
96
+ canvas,
97
+ storage,
98
+ autosave: { delay: 1000, maxWait: 10000 },
99
+ saveRetry: { attempts: 3, baseDelay: 500, maxDelay: 8000 },
100
+ });
101
+
102
+ engine.on('save:status', ({ status, isDirty, revision, lastSavedAt, error }) => {
103
+ statusLabel.textContent = status;
104
+ });
105
+ ```
106
+
107
+ `status` is one of `saved`, `unsaved`, `saving`, `error` or `conflict`.
108
+
109
+ - **Unsaved changes.** Every recorded history step, undo, redo and metadata change marks the document as changed. `engine.isDirty()` tells you whether anything is unsaved. Edits made while a save is running stay unsaved until the next save.
110
+ - **Autosave.** It saves after `delay` ms without edits, and at the latest `maxWait` ms after the first unsaved edit, even while the user keeps editing. `autosave: true` uses the defaults shown above.
111
+ - **One save at a time.** Calling `save()` while a save is running queues exactly one follow-up save of the latest content. Responses can never arrive out of order.
112
+ - **Stale responses.** If another document is loaded while a save is running, that save's response is ignored and any queued save is cancelled with `SAVE_CANCELLED`.
113
+ - **Conflicts.** When another tab or device saved first, the save fails with `SAVE_CONFLICT` and the status becomes `conflict`. Either reload the document with `engine.load(id)`, or keep your version with `engine.save({ overwrite: true })`.
114
+ - **Retries.** Temporary failures are retried with exponential backoff and jitter. Each retry emits `save:retry` with `{ attempt, delay, error }`.
115
+
116
+ ### Warn before leaving
117
+
118
+ ```ts
119
+ import { bindUnsavedChangesWarning } from 'fabricjs-document-engine';
120
+
121
+ const unbind = bindUnsavedChangesWarning(engine);
122
+ ```
123
+
54
124
  ## Custom objects
55
125
 
56
126
  ```ts
@@ -125,6 +195,7 @@ interface FabricDocument {
125
195
  id: string;
126
196
  createdAt: string;
127
197
  updatedAt: string;
198
+ revision?: number;
128
199
  fabricVersion?: string;
129
200
  canvas: { width: number; height: number; background?: unknown };
130
201
  objects: SerializedFabricObject[];
@@ -141,7 +212,10 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
141
212
  | `createDocumentEngine({ canvas, storage?, customObjects?, document? })` | Connects the engine to your canvas. |
142
213
  | `engine.toDocument()` | Serializes the canvas into the versioned document format. |
143
214
  | `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
144
- | `engine.load(id)` / `engine.save()` | Reads from or writes to your storage adapter. |
215
+ | `engine.load(id)` / `engine.save({ overwrite? })` | Reads from or writes to your storage adapter. |
216
+ | `engine.isDirty()` | Tells you whether there are unsaved changes. |
217
+ | `engine.getSaveState()` | Returns `{ status, isDirty, isSaving, revision, lastSavedAt, error }`. |
218
+ | `bindUnsavedChangesWarning(engine)` | Asks the browser to confirm before closing a page with unsaved changes. Returns an unbind function. |
145
219
  | `engine.newDocument({ id?, metadata? })` | Clears the canvas and starts a fresh document. |
146
220
  | `engine.getDocumentInfo()` | Returns the current document's id, dates and metadata. |
147
221
  | `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
@@ -154,7 +228,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
154
228
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
155
229
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
156
230
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
157
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
231
+ | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `history:change` or `history:error`. Returns an unsubscribe function. |
158
232
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
159
233
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
160
234
 
@@ -169,7 +243,10 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
169
243
  | `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
170
244
  | `LOAD_FAILED` | Fabric or your storage could not load the document, for example because an image is missing. `error.cause` holds the original error. |
171
245
  | `LOAD_ABORTED` | A newer load started before this one finished. |
172
- | `SAVE_FAILED` | Your storage adapter rejected the save. |
246
+ | `SAVE_FAILED` | Your storage adapter rejected the save after all retries. `error.retryable` tells you whether trying again could help. |
247
+ | `SAVE_CONFLICT` | Another tab or device saved this document first. |
248
+ | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
249
+ | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
173
250
  | `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. |
174
251
  | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
175
252
  | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
@@ -183,7 +260,7 @@ A failed load never clears or half-fills your canvas.
183
260
  | --- | --- |
184
261
  | 0.1 ✓ | Document foundation: ids, save and load, validation, custom objects |
185
262
  | 0.2 ✓ | Undo and redo with transactions |
186
- | 0.3 | Safe saving: dirty state, autosave, stale-response protection |
263
+ | 0.3 ✓ | Safe saving: dirty state, autosave, stale-response protection |
187
264
  | 0.4 | Assets and fonts |
188
265
  | 0.5 | Recovery after a refresh or crash |
189
266
  | 0.6 | PNG, JPEG, SVG and JSON export with preflight checks |
@@ -17,6 +17,7 @@ export interface FabricDocument {
17
17
  id: string;
18
18
  createdAt: string;
19
19
  updatedAt: string;
20
+ revision?: number;
20
21
  fabricVersion?: string;
21
22
  canvas: DocumentCanvas;
22
23
  objects: SerializedFabricObject[];
@@ -17,6 +17,7 @@ export interface FabricDocument {
17
17
  id: string;
18
18
  createdAt: string;
19
19
  updatedAt: string;
20
+ revision?: number;
20
21
  fabricVersion?: string;
21
22
  canvas: DocumentCanvas;
22
23
  objects: SerializedFabricObject[];
@@ -57,6 +57,7 @@ function validateDocument(value) {
57
57
  if (typeof value.id !== "string" || value.id.length === 0) issues.push(invalid("id", "must be a non-empty string"));
58
58
  if (typeof value.createdAt !== "string") issues.push(invalid("createdAt", "must be an ISO date string"));
59
59
  if (typeof value.updatedAt !== "string") issues.push(invalid("updatedAt", "must be an ISO date string"));
60
+ if (value.revision !== void 0 && !(Number.isInteger(value.revision) && value.revision >= 0)) issues.push(invalid("revision", "must be a whole number of zero or more when present"));
60
61
  if (!isPlainObject(value.metadata)) issues.push(invalid("metadata", "must be an object"));
61
62
  if (!isPlainObject(value.canvas)) issues.push(invalid("canvas", "must be an object"));
62
63
  else {
@@ -57,6 +57,7 @@ function validateDocument(value) {
57
57
  if (typeof value.id !== "string" || value.id.length === 0) issues.push(invalid("id", "must be a non-empty string"));
58
58
  if (typeof value.createdAt !== "string") issues.push(invalid("createdAt", "must be an ISO date string"));
59
59
  if (typeof value.updatedAt !== "string") issues.push(invalid("updatedAt", "must be an ISO date string"));
60
+ if (value.revision !== void 0 && !(Number.isInteger(value.revision) && value.revision >= 0)) issues.push(invalid("revision", "must be a whole number of zero or more when present"));
60
61
  if (!isPlainObject(value.metadata)) issues.push(invalid("metadata", "must be an object"));
61
62
  if (!isPlainObject(value.canvas)) issues.push(invalid("canvas", "must be an object"));
62
63
  else {
@@ -8,6 +8,7 @@ const require_object_ids = require("../fabric/object-ids.cjs");
8
8
  const require_errors = require("./errors.cjs");
9
9
  const require_object_registry = require("../fabric/object-registry.cjs");
10
10
  const require_create_history = require("../history/create-history.cjs");
11
+ const require_save_controller = require("../save/save-controller.cjs");
11
12
  const require_event_emitter = require("./event-emitter.cjs");
12
13
  //#region src/engine/create-document-engine.ts
13
14
  function describeDocumentId(value) {
@@ -16,6 +17,7 @@ function describeDocumentId(value) {
16
17
  }
17
18
  function createDocumentEngine(options) {
18
19
  const { canvas, storage } = options;
20
+ if (options.autosave && !storage) throw new require_errors.DocumentEngineError("STORAGE_MISSING", "Autosave needs a storage adapter passed to createDocumentEngine");
19
21
  const registry = require_object_registry.createObjectRegistry(options.customObjects);
20
22
  const events = require_event_emitter.createEventEmitter();
21
23
  const objectsById = /* @__PURE__ */ new Map();
@@ -58,7 +60,19 @@ function createDocumentEngine(options) {
58
60
  rebuildIndex();
59
61
  return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
60
62
  },
61
- onChange: (state) => events.emit("history:change", state)
63
+ onChange: (state) => events.emit("history:change", state),
64
+ onContentChange: () => saving.noteContentChange()
65
+ });
66
+ const saving = require_save_controller.createSaveController({
67
+ getStorage: () => requireStorage(),
68
+ createDocument: () => toDocument(),
69
+ retry: options.saveRetry,
70
+ autosave: options.autosave === true ? {} : options.autosave ?? false,
71
+ onStateChange: (state) => events.emit("save:status", state),
72
+ onStart: (document) => events.emit("save:start", { document }),
73
+ onSuccess: (document) => events.emit("save:success", { document }),
74
+ onError: (error) => events.emit("save:error", { error }),
75
+ onRetry: (event) => events.emit("save:retry", event)
62
76
  });
63
77
  function toDocument() {
64
78
  ensureUsable();
@@ -73,6 +87,7 @@ function createDocumentEngine(options) {
73
87
  id: documentInfo.id,
74
88
  createdAt: documentInfo.createdAt,
75
89
  updatedAt: documentInfo.updatedAt,
90
+ revision: saving.state().revision,
76
91
  canvas: {
77
92
  width: canvas.getWidth(),
78
93
  height: canvas.getHeight()
@@ -127,6 +142,7 @@ function createDocumentEngine(options) {
127
142
  };
128
143
  rebuildIndex();
129
144
  history.reset();
145
+ saving.startSession(document.revision ?? 0);
130
146
  canvas.requestRenderAll();
131
147
  events.emit("load:success", { document });
132
148
  return document;
@@ -150,27 +166,19 @@ function createDocumentEngine(options) {
150
166
  stored = await source.loadDocument(documentId);
151
167
  } catch (error) {
152
168
  const reason = error instanceof Error ? error.message : String(error);
153
- const engineError = new require_errors.DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
169
+ const engineError = require_errors.isDocumentEngineError(error) ? error : new require_errors.DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
154
170
  events.emit("load:error", { error: engineError });
155
171
  throw engineError;
156
172
  }
157
173
  return loadDocument(stored, loadOptions);
158
174
  }
159
- async function save() {
160
- ensureUsable();
161
- const target = requireStorage();
162
- const document = toDocument();
163
- events.emit("save:start", { document });
175
+ function save(saveOptions) {
164
176
  try {
165
- await target.saveDocument(document);
177
+ ensureUsable();
166
178
  } catch (error) {
167
- const reason = error instanceof Error ? error.message : String(error);
168
- const engineError = new require_errors.DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, { cause: error });
169
- events.emit("save:error", { error: engineError });
170
- throw engineError;
179
+ return Promise.reject(error);
171
180
  }
172
- events.emit("save:success", { document });
173
- return document;
181
+ return saving.save(saveOptions);
174
182
  }
175
183
  function newDocument(newOptions) {
176
184
  ensureUsable();
@@ -179,6 +187,7 @@ function createDocumentEngine(options) {
179
187
  objectsById.clear();
180
188
  documentInfo = require_create_document.createDocumentInfo(newOptions);
181
189
  history.reset();
190
+ saving.startSession(0);
182
191
  }
183
192
  function getObjectById(id) {
184
193
  ensureUsable();
@@ -203,6 +212,7 @@ function createDocumentEngine(options) {
203
212
  destroyed = true;
204
213
  activeLoad?.abort();
205
214
  history.destroy();
215
+ saving.destroy();
206
216
  canvas.off("object:added", handleObjectAdded);
207
217
  canvas.off("object:removed", handleObjectRemoved);
208
218
  objectsById.clear();
@@ -223,12 +233,15 @@ function createDocumentEngine(options) {
223
233
  ...changes
224
234
  }
225
235
  };
236
+ saving.noteContentChange();
226
237
  },
227
238
  newDocument,
228
239
  toDocument,
229
240
  loadDocument,
230
241
  load,
231
242
  save,
243
+ isDirty: () => saving.state().isDirty,
244
+ getSaveState: () => saving.state(),
232
245
  registerObject(definition) {
233
246
  ensureUsable();
234
247
  registry.register(definition);
@@ -2,20 +2,22 @@ import { DocumentInfo, FabricDocument } from "../document/document-format.cjs";
2
2
  import { NewDocumentOptions } from "../document/create-document.cjs";
3
3
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
4
4
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
5
+ import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
6
+ import { RetryOptions } from "../save/retry.cjs";
5
7
  import { DocumentEngineError } from "./errors.cjs";
8
+ import { DocumentStorage } from "../storage/storage-contract.cjs";
9
+ import { SaveOptions, SaveRetryEvent, SaveState } from "../save/save-controller.cjs";
6
10
  import { Unsubscribe } from "./event-emitter.cjs";
7
11
  import { FabricObject, StaticCanvas } from "fabric";
8
12
  //#region src/engine/create-document-engine.d.ts
9
- export interface DocumentStorage {
10
- loadDocument(id: string): Promise<unknown>;
11
- saveDocument(document: FabricDocument): Promise<void>;
12
- }
13
13
  export interface DocumentEngineOptions {
14
14
  canvas: StaticCanvas;
15
15
  storage?: DocumentStorage;
16
16
  customObjects?: CustomObjectDefinition[];
17
17
  document?: NewDocumentOptions;
18
18
  history?: HistoryOptions;
19
+ autosave?: boolean | AutosaveOptions;
20
+ saveRetry?: RetryOptions;
19
21
  }
20
22
  export interface LoadOptions {
21
23
  restoreCanvasSize?: boolean;
@@ -39,6 +41,8 @@ export interface DocumentEngineEvents {
39
41
  "save:error": {
40
42
  error: DocumentEngineError;
41
43
  };
44
+ "save:retry": SaveRetryEvent;
45
+ "save:status": SaveState;
42
46
  "history:change": HistoryState;
43
47
  "history:error": {
44
48
  error: DocumentEngineError;
@@ -52,7 +56,9 @@ export interface DocumentEngine {
52
56
  toDocument(): FabricDocument;
53
57
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
54
58
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
55
- save(): Promise<FabricDocument>;
59
+ save(options?: SaveOptions): Promise<FabricDocument>;
60
+ isDirty(): boolean;
61
+ getSaveState(): SaveState;
56
62
  registerObject(definition: CustomObjectDefinition): void;
57
63
  transaction<Result>(label: string, work: () => Result): Result;
58
64
  commit(label?: string): boolean;
@@ -2,20 +2,22 @@ import { DocumentInfo, FabricDocument } from "../document/document-format.js";
2
2
  import { NewDocumentOptions } from "../document/create-document.js";
3
3
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
4
4
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
5
+ import { AutosaveOptions } from "../save/autosave-scheduler.js";
6
+ import { RetryOptions } from "../save/retry.js";
5
7
  import { DocumentEngineError } from "./errors.js";
8
+ import { DocumentStorage } from "../storage/storage-contract.js";
9
+ import { SaveOptions, SaveRetryEvent, SaveState } from "../save/save-controller.js";
6
10
  import { Unsubscribe } from "./event-emitter.js";
7
11
  import { FabricObject, StaticCanvas } from "fabric";
8
12
  //#region src/engine/create-document-engine.d.ts
9
- export interface DocumentStorage {
10
- loadDocument(id: string): Promise<unknown>;
11
- saveDocument(document: FabricDocument): Promise<void>;
12
- }
13
13
  export interface DocumentEngineOptions {
14
14
  canvas: StaticCanvas;
15
15
  storage?: DocumentStorage;
16
16
  customObjects?: CustomObjectDefinition[];
17
17
  document?: NewDocumentOptions;
18
18
  history?: HistoryOptions;
19
+ autosave?: boolean | AutosaveOptions;
20
+ saveRetry?: RetryOptions;
19
21
  }
20
22
  export interface LoadOptions {
21
23
  restoreCanvasSize?: boolean;
@@ -39,6 +41,8 @@ export interface DocumentEngineEvents {
39
41
  "save:error": {
40
42
  error: DocumentEngineError;
41
43
  };
44
+ "save:retry": SaveRetryEvent;
45
+ "save:status": SaveState;
42
46
  "history:change": HistoryState;
43
47
  "history:error": {
44
48
  error: DocumentEngineError;
@@ -52,7 +56,9 @@ export interface DocumentEngine {
52
56
  toDocument(): FabricDocument;
53
57
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
54
58
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
55
- save(): Promise<FabricDocument>;
59
+ save(options?: SaveOptions): Promise<FabricDocument>;
60
+ isDirty(): boolean;
61
+ getSaveState(): SaveState;
56
62
  registerObject(definition: CustomObjectDefinition): void;
57
63
  transaction<Result>(label: string, work: () => Result): Result;
58
64
  commit(label?: string): boolean;
@@ -8,6 +8,7 @@ import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
8
8
  import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
9
9
  import { createObjectRegistry } from "../fabric/object-registry.js";
10
10
  import { createHistory } from "../history/create-history.js";
11
+ import { createSaveController } from "../save/save-controller.js";
11
12
  import { createEventEmitter } from "./event-emitter.js";
12
13
  //#region src/engine/create-document-engine.ts
13
14
  function describeDocumentId(value) {
@@ -16,6 +17,7 @@ function describeDocumentId(value) {
16
17
  }
17
18
  function createDocumentEngine(options) {
18
19
  const { canvas, storage } = options;
20
+ if (options.autosave && !storage) throw new DocumentEngineError("STORAGE_MISSING", "Autosave needs a storage adapter passed to createDocumentEngine");
19
21
  const registry = createObjectRegistry(options.customObjects);
20
22
  const events = createEventEmitter();
21
23
  const objectsById = /* @__PURE__ */ new Map();
@@ -58,7 +60,19 @@ function createDocumentEngine(options) {
58
60
  rebuildIndex();
59
61
  return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
60
62
  },
61
- onChange: (state) => events.emit("history:change", state)
63
+ onChange: (state) => events.emit("history:change", state),
64
+ onContentChange: () => saving.noteContentChange()
65
+ });
66
+ const saving = createSaveController({
67
+ getStorage: () => requireStorage(),
68
+ createDocument: () => toDocument(),
69
+ retry: options.saveRetry,
70
+ autosave: options.autosave === true ? {} : options.autosave ?? false,
71
+ onStateChange: (state) => events.emit("save:status", state),
72
+ onStart: (document) => events.emit("save:start", { document }),
73
+ onSuccess: (document) => events.emit("save:success", { document }),
74
+ onError: (error) => events.emit("save:error", { error }),
75
+ onRetry: (event) => events.emit("save:retry", event)
62
76
  });
63
77
  function toDocument() {
64
78
  ensureUsable();
@@ -73,6 +87,7 @@ function createDocumentEngine(options) {
73
87
  id: documentInfo.id,
74
88
  createdAt: documentInfo.createdAt,
75
89
  updatedAt: documentInfo.updatedAt,
90
+ revision: saving.state().revision,
76
91
  canvas: {
77
92
  width: canvas.getWidth(),
78
93
  height: canvas.getHeight()
@@ -127,6 +142,7 @@ function createDocumentEngine(options) {
127
142
  };
128
143
  rebuildIndex();
129
144
  history.reset();
145
+ saving.startSession(document.revision ?? 0);
130
146
  canvas.requestRenderAll();
131
147
  events.emit("load:success", { document });
132
148
  return document;
@@ -150,27 +166,19 @@ function createDocumentEngine(options) {
150
166
  stored = await source.loadDocument(documentId);
151
167
  } catch (error) {
152
168
  const reason = error instanceof Error ? error.message : String(error);
153
- const engineError = new DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
169
+ const engineError = isDocumentEngineError(error) ? error : new DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
154
170
  events.emit("load:error", { error: engineError });
155
171
  throw engineError;
156
172
  }
157
173
  return loadDocument(stored, loadOptions);
158
174
  }
159
- async function save() {
160
- ensureUsable();
161
- const target = requireStorage();
162
- const document = toDocument();
163
- events.emit("save:start", { document });
175
+ function save(saveOptions) {
164
176
  try {
165
- await target.saveDocument(document);
177
+ ensureUsable();
166
178
  } catch (error) {
167
- const reason = error instanceof Error ? error.message : String(error);
168
- const engineError = new DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, { cause: error });
169
- events.emit("save:error", { error: engineError });
170
- throw engineError;
179
+ return Promise.reject(error);
171
180
  }
172
- events.emit("save:success", { document });
173
- return document;
181
+ return saving.save(saveOptions);
174
182
  }
175
183
  function newDocument(newOptions) {
176
184
  ensureUsable();
@@ -179,6 +187,7 @@ function createDocumentEngine(options) {
179
187
  objectsById.clear();
180
188
  documentInfo = createDocumentInfo(newOptions);
181
189
  history.reset();
190
+ saving.startSession(0);
182
191
  }
183
192
  function getObjectById(id) {
184
193
  ensureUsable();
@@ -203,6 +212,7 @@ function createDocumentEngine(options) {
203
212
  destroyed = true;
204
213
  activeLoad?.abort();
205
214
  history.destroy();
215
+ saving.destroy();
206
216
  canvas.off("object:added", handleObjectAdded);
207
217
  canvas.off("object:removed", handleObjectRemoved);
208
218
  objectsById.clear();
@@ -223,12 +233,15 @@ function createDocumentEngine(options) {
223
233
  ...changes
224
234
  }
225
235
  };
236
+ saving.noteContentChange();
226
237
  },
227
238
  newDocument,
228
239
  toDocument,
229
240
  loadDocument,
230
241
  load,
231
242
  save,
243
+ isDirty: () => saving.state().isDirty,
244
+ getSaveState: () => saving.state(),
232
245
  registerObject(definition) {
233
246
  ensureUsable();
234
247
  registry.register(definition);
@@ -7,11 +7,16 @@ var DocumentEngineError = class extends Error {
7
7
  this.issues = details.issues ?? [];
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
9
  this.cause = details.cause;
10
+ this.retryable = details.retryable ?? false;
10
11
  }
11
12
  };
12
13
  function isDocumentEngineError(value) {
13
14
  return value instanceof DocumentEngineError;
14
15
  }
16
+ function createConflictError(documentId, expectedRevision, actualRevision) {
17
+ return new DocumentEngineError("SAVE_CONFLICT", `Document "${documentId}" was saved somewhere else: expected revision ${expectedRevision} but storage has ${actualRevision}`);
18
+ }
15
19
  //#endregion
16
20
  exports.DocumentEngineError = DocumentEngineError;
21
+ exports.createConflictError = createConflictError;
17
22
  exports.isDocumentEngineError = isDocumentEngineError;
@@ -1,5 +1,5 @@
1
1
  //#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" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
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
3
  export interface DocumentIssue {
4
4
  code: DocumentErrorCode;
5
5
  path: string;
@@ -9,13 +9,16 @@ export interface DocumentEngineErrorDetails {
9
9
  issues?: DocumentIssue[];
10
10
  unknownTypes?: string[];
11
11
  cause?: unknown;
12
+ retryable?: boolean;
12
13
  }
13
14
  export declare class DocumentEngineError extends Error {
14
15
  readonly code: DocumentErrorCode;
15
16
  readonly issues: DocumentIssue[];
16
17
  readonly unknownTypes: string[];
17
18
  readonly cause: unknown;
19
+ readonly retryable: boolean;
18
20
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
19
21
  }
20
22
  export declare function isDocumentEngineError(value: unknown): value is DocumentEngineError;
23
+ export declare function createConflictError(documentId: string, expectedRevision: number, actualRevision: number): DocumentEngineError;
21
24
  //#endregion
@@ -1,5 +1,5 @@
1
1
  //#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" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
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
3
  export interface DocumentIssue {
4
4
  code: DocumentErrorCode;
5
5
  path: string;
@@ -9,13 +9,16 @@ export interface DocumentEngineErrorDetails {
9
9
  issues?: DocumentIssue[];
10
10
  unknownTypes?: string[];
11
11
  cause?: unknown;
12
+ retryable?: boolean;
12
13
  }
13
14
  export declare class DocumentEngineError extends Error {
14
15
  readonly code: DocumentErrorCode;
15
16
  readonly issues: DocumentIssue[];
16
17
  readonly unknownTypes: string[];
17
18
  readonly cause: unknown;
19
+ readonly retryable: boolean;
18
20
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
19
21
  }
20
22
  export declare function isDocumentEngineError(value: unknown): value is DocumentEngineError;
23
+ export declare function createConflictError(documentId: string, expectedRevision: number, actualRevision: number): DocumentEngineError;
21
24
  //#endregion
@@ -7,10 +7,14 @@ var DocumentEngineError = class extends Error {
7
7
  this.issues = details.issues ?? [];
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
9
  this.cause = details.cause;
10
+ this.retryable = details.retryable ?? false;
10
11
  }
11
12
  };
12
13
  function isDocumentEngineError(value) {
13
14
  return value instanceof DocumentEngineError;
14
15
  }
16
+ function createConflictError(documentId, expectedRevision, actualRevision) {
17
+ return new DocumentEngineError("SAVE_CONFLICT", `Document "${documentId}" was saved somewhere else: expected revision ${expectedRevision} but storage has ${actualRevision}`);
18
+ }
15
19
  //#endregion
16
- export { DocumentEngineError, isDocumentEngineError };
20
+ export { DocumentEngineError, createConflictError, isDocumentEngineError };
@@ -24,7 +24,7 @@ function describePendingChanges(changes) {
24
24
  return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
25
25
  }
26
26
  function createHistory(options) {
27
- const { canvas, serializeObjects, onChange } = options;
27
+ const { canvas, serializeObjects, onChange, onContentChange } = options;
28
28
  const stack = require_history_stack.createHistoryStack(Math.max(1, options.limit ?? 100));
29
29
  let snapshot = require_snapshot.createSnapshot(serializeObjects());
30
30
  let pendingChanges = [];
@@ -53,6 +53,7 @@ function createHistory(options) {
53
53
  label,
54
54
  ...difference
55
55
  });
56
+ onContentChange();
56
57
  onChange(state());
57
58
  return true;
58
59
  }
@@ -155,6 +156,7 @@ function createHistory(options) {
155
156
  }
156
157
  snapshot = require_snapshot.createSnapshot(serializeObjects());
157
158
  moveTo(step);
159
+ onContentChange();
158
160
  onChange(state());
159
161
  return true;
160
162
  });
@@ -24,7 +24,7 @@ function describePendingChanges(changes) {
24
24
  return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
25
25
  }
26
26
  function createHistory(options) {
27
- const { canvas, serializeObjects, onChange } = options;
27
+ const { canvas, serializeObjects, onChange, onContentChange } = options;
28
28
  const stack = createHistoryStack(Math.max(1, options.limit ?? 100));
29
29
  let snapshot = createSnapshot(serializeObjects());
30
30
  let pendingChanges = [];
@@ -53,6 +53,7 @@ function createHistory(options) {
53
53
  label,
54
54
  ...difference
55
55
  });
56
+ onContentChange();
56
57
  onChange(state());
57
58
  return true;
58
59
  }
@@ -155,6 +156,7 @@ function createHistory(options) {
155
156
  }
156
157
  snapshot = createSnapshot(serializeObjects());
157
158
  moveTo(step);
159
+ onContentChange();
158
160
  onChange(state());
159
161
  return true;
160
162
  });