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.
- package/README.md +87 -10
- package/dist/document/document-format.d.cts +1 -0
- package/dist/document/document-format.d.ts +1 -0
- package/dist/document/validate-document.cjs +1 -0
- package/dist/document/validate-document.js +1 -0
- package/dist/engine/create-document-engine.cjs +27 -14
- package/dist/engine/create-document-engine.d.cts +11 -5
- package/dist/engine/create-document-engine.d.ts +11 -5
- package/dist/engine/create-document-engine.js +27 -14
- package/dist/engine/errors.cjs +5 -0
- package/dist/engine/errors.d.cts +4 -1
- package/dist/engine/errors.d.ts +4 -1
- package/dist/engine/errors.js +5 -1
- package/dist/history/create-history.cjs +3 -1
- package/dist/history/create-history.js +3 -1
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +8 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +3 -2
- package/dist/save/autosave-scheduler.cjs +28 -0
- package/dist/save/autosave-scheduler.d.cts +6 -0
- package/dist/save/autosave-scheduler.d.ts +6 -0
- package/dist/save/autosave-scheduler.js +28 -0
- package/dist/save/retry.cjs +33 -0
- package/dist/save/retry.d.cts +7 -0
- package/dist/save/retry.d.ts +7 -0
- package/dist/save/retry.js +31 -0
- package/dist/save/save-controller.cjs +148 -0
- package/dist/save/save-controller.d.cts +20 -0
- package/dist/save/save-controller.d.ts +20 -0
- package/dist/save/save-controller.js +148 -0
- package/dist/save/unsaved-changes-warning.cjs +12 -0
- package/dist/save/unsaved-changes-warning.d.cts +6 -0
- package/dist/save/unsaved-changes-warning.d.ts +6 -0
- package/dist/save/unsaved-changes-warning.js +12 -0
- package/dist/storage/key-value-storage.cjs +35 -0
- package/dist/storage/key-value-storage.d.cts +14 -0
- package/dist/storage/key-value-storage.d.ts +14 -0
- package/dist/storage/key-value-storage.js +35 -0
- package/dist/storage/local-storage.cjs +24 -0
- package/dist/storage/local-storage.d.cts +8 -0
- package/dist/storage/local-storage.d.ts +8 -0
- package/dist/storage/local-storage.js +24 -0
- package/dist/storage/memory-storage.cjs +17 -0
- package/dist/storage/memory-storage.d.cts +4 -0
- package/dist/storage/memory-storage.d.ts +4 -0
- package/dist/storage/memory-storage.js +17 -0
- package/dist/storage/storage-contract.d.cts +14 -0
- package/dist/storage/storage-contract.d.ts +14 -0
- package/dist/storage.cjs +7 -0
- package/dist/storage.d.cts +5 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +4 -0
- 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
|
-
- **
|
|
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
|
-
|
|
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 |
|
|
@@ -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
|
-
|
|
160
|
-
ensureUsable();
|
|
161
|
-
const target = requireStorage();
|
|
162
|
-
const document = toDocument();
|
|
163
|
-
events.emit("save:start", { document });
|
|
175
|
+
function save(saveOptions) {
|
|
164
176
|
try {
|
|
165
|
-
|
|
177
|
+
ensureUsable();
|
|
166
178
|
} catch (error) {
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
ensureUsable();
|
|
161
|
-
const target = requireStorage();
|
|
162
|
-
const document = toDocument();
|
|
163
|
-
events.emit("save:start", { document });
|
|
175
|
+
function save(saveOptions) {
|
|
164
176
|
try {
|
|
165
|
-
|
|
177
|
+
ensureUsable();
|
|
166
178
|
} catch (error) {
|
|
167
|
-
|
|
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
|
-
|
|
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);
|
package/dist/engine/errors.cjs
CHANGED
|
@@ -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;
|
package/dist/engine/errors.d.cts
CHANGED
|
@@ -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
|
package/dist/engine/errors.d.ts
CHANGED
|
@@ -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
|
package/dist/engine/errors.js
CHANGED
|
@@ -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
|
});
|