fabricjs-document-engine 0.0.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 (68) hide show
  1. package/README.md +146 -12
  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 +70 -16
  7. package/dist/engine/create-document-engine.d.cts +28 -5
  8. package/dist/engine/create-document-engine.d.ts +28 -5
  9. package/dist/engine/create-document-engine.js +70 -16
  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/fabric/fabric-adapter.cjs +7 -2
  15. package/dist/fabric/fabric-adapter.js +7 -3
  16. package/dist/history/apply-state.cjs +48 -0
  17. package/dist/history/apply-state.js +48 -0
  18. package/dist/history/create-history.cjs +193 -0
  19. package/dist/history/create-history.d.cts +12 -0
  20. package/dist/history/create-history.d.ts +12 -0
  21. package/dist/history/create-history.js +192 -0
  22. package/dist/history/history-stack.cjs +27 -0
  23. package/dist/history/history-stack.js +27 -0
  24. package/dist/history/keyboard-shortcuts.cjs +32 -0
  25. package/dist/history/keyboard-shortcuts.d.cts +10 -0
  26. package/dist/history/keyboard-shortcuts.d.ts +10 -0
  27. package/dist/history/keyboard-shortcuts.js +32 -0
  28. package/dist/history/snapshot.cjs +47 -0
  29. package/dist/history/snapshot.js +46 -0
  30. package/dist/index.cjs +5 -0
  31. package/dist/index.d.cts +10 -3
  32. package/dist/index.d.ts +10 -3
  33. package/dist/index.js +4 -2
  34. package/dist/save/autosave-scheduler.cjs +28 -0
  35. package/dist/save/autosave-scheduler.d.cts +6 -0
  36. package/dist/save/autosave-scheduler.d.ts +6 -0
  37. package/dist/save/autosave-scheduler.js +28 -0
  38. package/dist/save/retry.cjs +33 -0
  39. package/dist/save/retry.d.cts +7 -0
  40. package/dist/save/retry.d.ts +7 -0
  41. package/dist/save/retry.js +31 -0
  42. package/dist/save/save-controller.cjs +148 -0
  43. package/dist/save/save-controller.d.cts +20 -0
  44. package/dist/save/save-controller.d.ts +20 -0
  45. package/dist/save/save-controller.js +148 -0
  46. package/dist/save/unsaved-changes-warning.cjs +12 -0
  47. package/dist/save/unsaved-changes-warning.d.cts +6 -0
  48. package/dist/save/unsaved-changes-warning.d.ts +6 -0
  49. package/dist/save/unsaved-changes-warning.js +12 -0
  50. package/dist/storage/key-value-storage.cjs +35 -0
  51. package/dist/storage/key-value-storage.d.cts +14 -0
  52. package/dist/storage/key-value-storage.d.ts +14 -0
  53. package/dist/storage/key-value-storage.js +35 -0
  54. package/dist/storage/local-storage.cjs +24 -0
  55. package/dist/storage/local-storage.d.cts +8 -0
  56. package/dist/storage/local-storage.d.ts +8 -0
  57. package/dist/storage/local-storage.js +24 -0
  58. package/dist/storage/memory-storage.cjs +17 -0
  59. package/dist/storage/memory-storage.d.cts +4 -0
  60. package/dist/storage/memory-storage.d.ts +4 -0
  61. package/dist/storage/memory-storage.js +17 -0
  62. package/dist/storage/storage-contract.d.cts +14 -0
  63. package/dist/storage/storage-contract.d.ts +14 -0
  64. package/dist/storage.cjs +7 -0
  65. package/dist/storage.d.cts +5 -0
  66. package/dist/storage.d.ts +5 -0
  67. package/dist/storage.js +4 -0
  68. package/package.json +14 -3
package/README.md CHANGED
@@ -8,7 +8,9 @@ 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.
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.
12
14
  - **Fabric 6 and 7.** Every release is tested against both.
13
15
 
14
16
  ## Install
@@ -36,20 +38,89 @@ await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
36
38
 
37
39
  ## Saving and loading through storage
38
40
 
41
+ The built-in adapters are the quickest way to start:
42
+
39
43
  ```ts
44
+ import { createLocalStorage, createMemoryStorage } from 'fabricjs-document-engine/storage';
45
+
40
46
  const engine = createDocumentEngine({
41
47
  canvas,
42
- storage: {
43
- loadDocument: (id) => fetch(`/api/documents/${id}`).then((response) => response.json()),
44
- saveDocument: (document) =>
45
- fetch(`/api/documents/${document.id}`, { method: 'PUT', body: JSON.stringify(document) }).then(() => {}),
46
- },
48
+ storage: createLocalStorage({ prefix: 'my-app:' }),
49
+ autosave: true,
47
50
  });
48
51
 
49
52
  await engine.load('project-42');
50
53
  await engine.save();
51
54
  ```
52
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
+
53
124
  ## Custom objects
54
125
 
55
126
  ```ts
@@ -68,6 +139,54 @@ const engine = createDocumentEngine({
68
139
 
69
140
  If a document contains a type that has not been registered, loading fails with `UNKNOWN_OBJECT_TYPE` and lists the missing types. Your object is never turned into something else.
70
141
 
142
+ ## Undo and redo
143
+
144
+ History is on by default. The engine records these automatically:
145
+
146
+ - adding and deleting objects, where several changes in the same tick become one step
147
+ - pointer moves, resizes and rotations (Fabric's `object:modified`)
148
+ - finished text editing
149
+
150
+ Fabric fires no events for changes your code makes directly, such as `object.set('fill', 'red')` or `canvas.bringObjectForward(object)`. Wrap them in a transaction or call `commit`:
151
+
152
+ ```ts
153
+ engine.transaction('Arrange furniture', () => {
154
+ chair.set({ left: 120, top: 80 });
155
+ table.set('fill', 'oak');
156
+ canvas.bringObjectToFront(table);
157
+ });
158
+
159
+ canvas.sendObjectBackwards(rug);
160
+ engine.commit('Send rug backwards');
161
+
162
+ await engine.undo();
163
+ await engine.redo();
164
+ ```
165
+
166
+ - Transactions can be nested, and the outermost label is used. They can also be async: `await engine.transaction('Import', async () => { ... })`.
167
+ - Grouping and ungrouping are ordinary changes to the object list. Do the remove and the add inside one transaction and they take one undo step.
168
+ - Undo and redo rebuild the changed objects from their saved state, so they come back as new instances with the same ids. Look them up again with `engine.getObjectById(id)` rather than keeping old references.
169
+ - Keep the last 50 steps with `createDocumentEngine({ canvas, history: { limit: 50 } })`. The default is 100.
170
+
171
+ ### Keyboard shortcuts
172
+
173
+ ```ts
174
+ import { bindKeyboardShortcuts } from 'fabricjs-document-engine';
175
+
176
+ const unbind = bindKeyboardShortcuts(engine);
177
+ ```
178
+
179
+ Ctrl/Cmd + Z undoes. Ctrl/Cmd + Shift + Z and Ctrl + Y redo. Shortcuts are ignored while the user is typing in an input, a textarea, a contenteditable element or Fabric text, so native text undo keeps working there. Pass `{ target: element }` to listen somewhere other than `window`.
180
+
181
+ ### Toolbar state
182
+
183
+ ```ts
184
+ engine.on('history:change', ({ canUndo, canRedo, undoLabel, redoLabel }) => {
185
+ undoButton.disabled = !canUndo;
186
+ undoButton.title = undoLabel ? `Undo ${undoLabel}` : 'Undo';
187
+ });
188
+ ```
189
+
71
190
  ## Document format
72
191
 
73
192
  ```ts
@@ -76,6 +195,7 @@ interface FabricDocument {
76
195
  id: string;
77
196
  createdAt: string;
78
197
  updatedAt: string;
198
+ revision?: number;
79
199
  fabricVersion?: string;
80
200
  canvas: { width: number; height: number; background?: unknown };
81
201
  objects: SerializedFabricObject[];
@@ -92,13 +212,23 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
92
212
  | `createDocumentEngine({ canvas, storage?, customObjects?, document? })` | Connects the engine to your canvas. |
93
213
  | `engine.toDocument()` | Serializes the canvas into the versioned document format. |
94
214
  | `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
95
- | `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. |
96
219
  | `engine.newDocument({ id?, metadata? })` | Clears the canvas and starts a fresh document. |
97
220
  | `engine.getDocumentInfo()` | Returns the current document's id, dates and metadata. |
98
221
  | `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
99
222
  | `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
100
223
  | `engine.registerObject({ fabricClass, properties })` | Registers a custom class after the engine is created. |
101
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success` or `save:error`. Returns an unsubscribe function. |
224
+ | `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
225
+ | `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
226
+ | `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
227
+ | `engine.canUndo()` / `engine.canRedo()` | Tells you whether a step is available. |
228
+ | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
229
+ | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
230
+ | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind 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. |
102
232
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
103
233
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
104
234
 
@@ -113,7 +243,11 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
113
243
  | `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
114
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. |
115
245
  | `LOAD_ABORTED` | A newer load started before this one finished. |
116
- | `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. |
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. |
117
251
  | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
118
252
  | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
119
253
  | `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
@@ -124,9 +258,9 @@ A failed load never clears or half-fills your canvas.
124
258
 
125
259
  | Version | Focus |
126
260
  | --- | --- |
127
- | 0.1 | Document foundation: ids, save and load, validation, custom objects |
128
- | 0.2 | Undo and redo with transactions |
129
- | 0.3 | Safe saving: dirty state, autosave, stale-response protection |
261
+ | 0.1 ✓ | Document foundation: ids, save and load, validation, custom objects |
262
+ | 0.2 ✓ | Undo and redo with transactions |
263
+ | 0.3 ✓ | Safe saving: dirty state, autosave, stale-response protection |
130
264
  | 0.4 | Assets and fonts |
131
265
  | 0.5 | Recovery after a refresh or crash |
132
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 {
@@ -7,6 +7,8 @@ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
7
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
+ const require_create_history = require("../history/create-history.cjs");
11
+ const require_save_controller = require("../save/save-controller.cjs");
10
12
  const require_event_emitter = require("./event-emitter.cjs");
11
13
  //#region src/engine/create-document-engine.ts
12
14
  function describeDocumentId(value) {
@@ -15,6 +17,7 @@ function describeDocumentId(value) {
15
17
  }
16
18
  function createDocumentEngine(options) {
17
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");
18
21
  const registry = require_object_registry.createObjectRegistry(options.customObjects);
19
22
  const events = require_event_emitter.createEventEmitter();
20
23
  const objectsById = /* @__PURE__ */ new Map();
@@ -50,6 +53,27 @@ function createDocumentEngine(options) {
50
53
  canvas.on("object:added", handleObjectAdded);
51
54
  canvas.on("object:removed", handleObjectRemoved);
52
55
  rebuildIndex();
56
+ const history = require_create_history.createHistory({
57
+ canvas,
58
+ limit: options.history?.limit,
59
+ serializeObjects: () => {
60
+ rebuildIndex();
61
+ return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
62
+ },
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)
76
+ });
53
77
  function toDocument() {
54
78
  ensureUsable();
55
79
  rebuildIndex();
@@ -63,6 +87,7 @@ function createDocumentEngine(options) {
63
87
  id: documentInfo.id,
64
88
  createdAt: documentInfo.createdAt,
65
89
  updatedAt: documentInfo.updatedAt,
90
+ revision: saving.state().revision,
66
91
  canvas: {
67
92
  width: canvas.getWidth(),
68
93
  height: canvas.getHeight()
@@ -99,11 +124,11 @@ function createDocumentEngine(options) {
99
124
  events.emit("load:start", { documentId: describeDocumentId(input) });
100
125
  try {
101
126
  const document = checkDocument(input);
102
- await require_fabric_adapter.loadIntoCanvas(canvas, {
127
+ await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
103
128
  version: document.fabricVersion,
104
129
  background: document.canvas.background,
105
130
  objects: document.objects
106
- }, controller.signal);
131
+ }, controller.signal));
107
132
  if (controller.signal.aborted) throw new Error("aborted");
108
133
  if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
109
134
  width: document.canvas.width,
@@ -116,6 +141,8 @@ function createDocumentEngine(options) {
116
141
  metadata: { ...document.metadata }
117
142
  };
118
143
  rebuildIndex();
144
+ history.reset();
145
+ saving.startSession(document.revision ?? 0);
119
146
  canvas.requestRenderAll();
120
147
  events.emit("load:success", { document });
121
148
  return document;
@@ -139,34 +166,28 @@ function createDocumentEngine(options) {
139
166
  stored = await source.loadDocument(documentId);
140
167
  } catch (error) {
141
168
  const reason = error instanceof Error ? error.message : String(error);
142
- 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 });
143
170
  events.emit("load:error", { error: engineError });
144
171
  throw engineError;
145
172
  }
146
173
  return loadDocument(stored, loadOptions);
147
174
  }
148
- async function save() {
149
- ensureUsable();
150
- const target = requireStorage();
151
- const document = toDocument();
152
- events.emit("save:start", { document });
175
+ function save(saveOptions) {
153
176
  try {
154
- await target.saveDocument(document);
177
+ ensureUsable();
155
178
  } catch (error) {
156
- const reason = error instanceof Error ? error.message : String(error);
157
- const engineError = new require_errors.DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, { cause: error });
158
- events.emit("save:error", { error: engineError });
159
- throw engineError;
179
+ return Promise.reject(error);
160
180
  }
161
- events.emit("save:success", { document });
162
- return document;
181
+ return saving.save(saveOptions);
163
182
  }
164
183
  function newDocument(newOptions) {
165
184
  ensureUsable();
166
185
  activeLoad?.abort();
167
- canvas.clear();
186
+ history.withoutRecording(() => canvas.clear());
168
187
  objectsById.clear();
169
188
  documentInfo = require_create_document.createDocumentInfo(newOptions);
189
+ history.reset();
190
+ saving.startSession(0);
170
191
  }
171
192
  function getObjectById(id) {
172
193
  ensureUsable();
@@ -175,10 +196,23 @@ function createDocumentEngine(options) {
175
196
  rebuildIndex();
176
197
  return objectsById.get(id);
177
198
  }
199
+ async function travelThroughHistory(direction) {
200
+ ensureUsable();
201
+ try {
202
+ return await history[direction]();
203
+ } catch (error) {
204
+ const reason = error instanceof Error ? error.message : String(error);
205
+ const engineError = new require_errors.DocumentEngineError("HISTORY_FAILED", `Could not ${direction}: ${reason}`, { cause: error });
206
+ events.emit("history:error", { error: engineError });
207
+ throw engineError;
208
+ }
209
+ }
178
210
  function destroy() {
179
211
  if (destroyed) return;
180
212
  destroyed = true;
181
213
  activeLoad?.abort();
214
+ history.destroy();
215
+ saving.destroy();
182
216
  canvas.off("object:added", handleObjectAdded);
183
217
  canvas.off("object:removed", handleObjectRemoved);
184
218
  objectsById.clear();
@@ -199,17 +233,37 @@ function createDocumentEngine(options) {
199
233
  ...changes
200
234
  }
201
235
  };
236
+ saving.noteContentChange();
202
237
  },
203
238
  newDocument,
204
239
  toDocument,
205
240
  loadDocument,
206
241
  load,
207
242
  save,
243
+ isDirty: () => saving.state().isDirty,
244
+ getSaveState: () => saving.state(),
208
245
  registerObject(definition) {
209
246
  ensureUsable();
210
247
  registry.register(definition);
211
248
  },
212
249
  getObjectById,
250
+ transaction(label, work) {
251
+ ensureUsable();
252
+ return history.transaction(label, work);
253
+ },
254
+ commit(label = "Edit") {
255
+ ensureUsable();
256
+ return history.commit(label);
257
+ },
258
+ undo: () => travelThroughHistory("undo"),
259
+ redo: () => travelThroughHistory("redo"),
260
+ canUndo: () => history.state().canUndo,
261
+ canRedo: () => history.state().canRedo,
262
+ getHistory: () => history.labels(),
263
+ clearHistory() {
264
+ ensureUsable();
265
+ history.reset();
266
+ },
213
267
  on: (name, handler) => events.on(name, handler),
214
268
  destroy
215
269
  };
@@ -1,19 +1,23 @@
1
1
  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
+ import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
5
+ import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
6
+ import { RetryOptions } from "../save/retry.cjs";
4
7
  import { DocumentEngineError } from "./errors.cjs";
8
+ import { DocumentStorage } from "../storage/storage-contract.cjs";
9
+ import { SaveOptions, SaveRetryEvent, SaveState } from "../save/save-controller.cjs";
5
10
  import { Unsubscribe } from "./event-emitter.cjs";
6
11
  import { FabricObject, StaticCanvas } from "fabric";
7
12
  //#region src/engine/create-document-engine.d.ts
8
- export interface DocumentStorage {
9
- loadDocument(id: string): Promise<unknown>;
10
- saveDocument(document: FabricDocument): Promise<void>;
11
- }
12
13
  export interface DocumentEngineOptions {
13
14
  canvas: StaticCanvas;
14
15
  storage?: DocumentStorage;
15
16
  customObjects?: CustomObjectDefinition[];
16
17
  document?: NewDocumentOptions;
18
+ history?: HistoryOptions;
19
+ autosave?: boolean | AutosaveOptions;
20
+ saveRetry?: RetryOptions;
17
21
  }
18
22
  export interface LoadOptions {
19
23
  restoreCanvasSize?: boolean;
@@ -37,6 +41,12 @@ export interface DocumentEngineEvents {
37
41
  "save:error": {
38
42
  error: DocumentEngineError;
39
43
  };
44
+ "save:retry": SaveRetryEvent;
45
+ "save:status": SaveState;
46
+ "history:change": HistoryState;
47
+ "history:error": {
48
+ error: DocumentEngineError;
49
+ };
40
50
  }
41
51
  export interface DocumentEngine {
42
52
  readonly canvas: StaticCanvas;
@@ -46,8 +56,21 @@ export interface DocumentEngine {
46
56
  toDocument(): FabricDocument;
47
57
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
48
58
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
49
- save(): Promise<FabricDocument>;
59
+ save(options?: SaveOptions): Promise<FabricDocument>;
60
+ isDirty(): boolean;
61
+ getSaveState(): SaveState;
50
62
  registerObject(definition: CustomObjectDefinition): void;
63
+ transaction<Result>(label: string, work: () => Result): Result;
64
+ commit(label?: string): boolean;
65
+ undo(): Promise<boolean>;
66
+ redo(): Promise<boolean>;
67
+ canUndo(): boolean;
68
+ canRedo(): boolean;
69
+ getHistory(): {
70
+ undo: string[];
71
+ redo: string[];
72
+ };
73
+ clearHistory(): void;
51
74
  getObjectById(id: string): FabricObject | undefined;
52
75
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
53
76
  destroy(): void;
@@ -1,19 +1,23 @@
1
1
  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
+ import { HistoryOptions, HistoryState } from "../history/create-history.js";
5
+ import { AutosaveOptions } from "../save/autosave-scheduler.js";
6
+ import { RetryOptions } from "../save/retry.js";
4
7
  import { DocumentEngineError } from "./errors.js";
8
+ import { DocumentStorage } from "../storage/storage-contract.js";
9
+ import { SaveOptions, SaveRetryEvent, SaveState } from "../save/save-controller.js";
5
10
  import { Unsubscribe } from "./event-emitter.js";
6
11
  import { FabricObject, StaticCanvas } from "fabric";
7
12
  //#region src/engine/create-document-engine.d.ts
8
- export interface DocumentStorage {
9
- loadDocument(id: string): Promise<unknown>;
10
- saveDocument(document: FabricDocument): Promise<void>;
11
- }
12
13
  export interface DocumentEngineOptions {
13
14
  canvas: StaticCanvas;
14
15
  storage?: DocumentStorage;
15
16
  customObjects?: CustomObjectDefinition[];
16
17
  document?: NewDocumentOptions;
18
+ history?: HistoryOptions;
19
+ autosave?: boolean | AutosaveOptions;
20
+ saveRetry?: RetryOptions;
17
21
  }
18
22
  export interface LoadOptions {
19
23
  restoreCanvasSize?: boolean;
@@ -37,6 +41,12 @@ export interface DocumentEngineEvents {
37
41
  "save:error": {
38
42
  error: DocumentEngineError;
39
43
  };
44
+ "save:retry": SaveRetryEvent;
45
+ "save:status": SaveState;
46
+ "history:change": HistoryState;
47
+ "history:error": {
48
+ error: DocumentEngineError;
49
+ };
40
50
  }
41
51
  export interface DocumentEngine {
42
52
  readonly canvas: StaticCanvas;
@@ -46,8 +56,21 @@ export interface DocumentEngine {
46
56
  toDocument(): FabricDocument;
47
57
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
48
58
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
49
- save(): Promise<FabricDocument>;
59
+ save(options?: SaveOptions): Promise<FabricDocument>;
60
+ isDirty(): boolean;
61
+ getSaveState(): SaveState;
50
62
  registerObject(definition: CustomObjectDefinition): void;
63
+ transaction<Result>(label: string, work: () => Result): Result;
64
+ commit(label?: string): boolean;
65
+ undo(): Promise<boolean>;
66
+ redo(): Promise<boolean>;
67
+ canUndo(): boolean;
68
+ canRedo(): boolean;
69
+ getHistory(): {
70
+ undo: string[];
71
+ redo: string[];
72
+ };
73
+ clearHistory(): void;
51
74
  getObjectById(id: string): FabricObject | undefined;
52
75
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
53
76
  destroy(): void;