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.
- package/README.md +146 -12
- 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 +70 -16
- package/dist/engine/create-document-engine.d.cts +28 -5
- package/dist/engine/create-document-engine.d.ts +28 -5
- package/dist/engine/create-document-engine.js +70 -16
- 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/fabric/fabric-adapter.cjs +7 -2
- package/dist/fabric/fabric-adapter.js +7 -3
- package/dist/history/apply-state.cjs +48 -0
- package/dist/history/apply-state.js +48 -0
- package/dist/history/create-history.cjs +193 -0
- package/dist/history/create-history.d.cts +12 -0
- package/dist/history/create-history.d.ts +12 -0
- package/dist/history/create-history.js +192 -0
- package/dist/history/history-stack.cjs +27 -0
- package/dist/history/history-stack.js +27 -0
- package/dist/history/keyboard-shortcuts.cjs +32 -0
- package/dist/history/keyboard-shortcuts.d.cts +10 -0
- package/dist/history/keyboard-shortcuts.d.ts +10 -0
- package/dist/history/keyboard-shortcuts.js +32 -0
- package/dist/history/snapshot.cjs +47 -0
- package/dist/history/snapshot.js +46 -0
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +4 -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,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
|
-
- **
|
|
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
|
-
|
|
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.
|
|
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 |
|
|
@@ -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
|
-
|
|
149
|
-
ensureUsable();
|
|
150
|
-
const target = requireStorage();
|
|
151
|
-
const document = toDocument();
|
|
152
|
-
events.emit("save:start", { document });
|
|
175
|
+
function save(saveOptions) {
|
|
153
176
|
try {
|
|
154
|
-
|
|
177
|
+
ensureUsable();
|
|
155
178
|
} catch (error) {
|
|
156
|
-
|
|
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
|
-
|
|
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;
|