fabricjs-document-engine 1.0.0 → 1.0.2

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 CHANGED
@@ -1,32 +1,41 @@
1
1
  # fabricjs-document-engine
2
2
 
3
- Turn an existing [Fabric.js](https://fabricjs.com) canvas into a dependable editable document.
3
+ Save, load, undo and redo for an existing [Fabric.js](https://fabricjs.com) canvas. Your objects keep their ids, and a slow save never overwrites newer work.
4
4
 
5
- Fabric already draws objects, handles interaction and serializes to JSON. This package coordinates those pieces into a document workflow you can trust. You keep your own canvas, toolbar and UI.
5
+ [![npm version](https://img.shields.io/npm/v/fabricjs-document-engine.svg)](https://www.npmjs.com/package/fabricjs-document-engine)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/fabricjs-document-engine)](https://bundlephobia.com/package/fabricjs-document-engine)
7
+ [![types](https://img.shields.io/npm/types/fabricjs-document-engine.svg)](https://www.npmjs.com/package/fabricjs-document-engine)
8
+ [![license](https://img.shields.io/npm/l/fabricjs-document-engine.svg)](https://github.com/re-sohail/fabricjs-document-engine/blob/main/LICENSE)
6
9
 
7
- - **Stable object ids.** Every object, including children of groups, gets an id that survives moving, styling, grouping, saving and reopening.
8
- - **A versioned document format.** It records the schema version, canvas size, background, object order and your own metadata.
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
- - **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
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
- - **Assets and fonts.** Documents record the images and fonts they need. When a document is opened, every image and font is checked first. You get the exact list of what is missing, can offer replacements, and tab-only images are uploaded when you save.
14
- - **Recovery.** Unsaved work is copied to IndexedDB while the user edits, and again at the moment the tab is closed or refreshed. After a crash or refresh you can offer to restore it, including images that only existed in the old tab.
15
- - **Dependable export.** PNG, JPEG, WebP, SVG and editable JSON. You choose the area, scale and background. A preflight check means an export either succeeds or tells you exactly which image or font prevents it.
16
- - **Versions and migration.** Keep named versions, restore any of them as a new revision, and open plain Fabric JSON or documents saved by older versions of this package.
17
- - **Reliable undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
18
- - **React ready, framework free.** Hooks for React, and a small state store for any other framework. Your toolbar and UI stay yours.
19
- - **Hardened.** Imported documents are cleaned and size-limited, undo history has a memory budget, and every feature is tested in Chromium, Firefox and WebKit. See the [compatibility and performance results](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility.md).
20
- - **Fabric 6 and 7.** Every release is tested against both.
10
+ [Documentation](https://fabricjs-document-engine.jscrate.dev) · [Live demos](https://fabricjs-document-engine.jscrate.dev/#examples-heading) · [Editor tutorial](https://fabricjs-document-engine.jscrate.dev/docs/overview/tutorial) · [API](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/api.md) · [中文](https://github.com/re-sohail/fabricjs-document-engine/blob/main/README.zh-CN.md)
11
+
12
+ You keep your own canvas, toolbar and UI. This package sits beside them and turns what is on the canvas into a document you can save, reopen and keep editing. It works with Fabric 6 and 7, in React, Next.js, Vue, Svelte or plain JavaScript.
13
+
14
+ ## Why this exists
15
+
16
+ Anyone who has shipped a fabricjs editor has hit the same walls. Fabric.js serialization and drawing work well, but a document needs more than that:
17
+
18
+ - **There is no undo.** Fabric has no built-in history, so every team writes its own and loses object references on the way ([fabric.js#10011](https://github.com/fabricjs/fabric.js/issues/10011)).
19
+ - **Custom properties vanish.** `toJSON` and `loadFromJSON` drop fields Fabric does not know about, unless you list them on every call ([fabric.js#10887](https://github.com/fabricjs/fabric.js/issues/10887)).
20
+ - **Objects cannot be found again.** After loading, you cannot get an object by id, because Fabric gives objects no stable id. Group children have none at all.
21
+ - **Saves race each other.** An older request can finish last and overwrite newer edits, or a second tab can save over the first.
22
+
23
+ This package handles those four problems and the ones behind them: missing images, fonts that fail to load, crashed tabs and old file formats.
21
24
 
22
25
  ## Install
23
26
 
27
+ Install from npm, together with Fabric:
28
+
24
29
  ```bash
25
30
  npm install fabricjs-document-engine fabric
26
31
  ```
27
32
 
33
+ The package is written in TypeScript and ships its own types. It has no runtime dependencies. `fabric` is a peer dependency, and React is needed only for the hooks.
34
+
28
35
  ## Quick start
29
36
 
37
+ Save a Fabric.js canvas as JSON, then load it back:
38
+
30
39
  ```ts
31
40
  import { Canvas, Rect } from 'fabric';
32
41
  import { createDocumentEngine } from 'fabricjs-document-engine';
@@ -42,7 +51,27 @@ localStorage.setItem(document.id, JSON.stringify(document));
42
51
  await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
43
52
  ```
44
53
 
45
- ## React
54
+ That is the whole setup for a first test. localStorage is fine here; in a real app you pass a storage adapter and let autosave do the work, as shown below. The [quick start guide](https://fabricjs-document-engine.jscrate.dev/docs/overview/quick-start) walks through it step by step.
55
+
56
+ ## What it handles
57
+
58
+ - **Stable object ids.** Every object, including children of groups, gets an id that survives moving, styling, grouping, saving and reopening. `engine.getObjectById(id)` finds it again.
59
+ - **A versioned document format.** It records the schema version, canvas size, background, object order and your own metadata.
60
+ - **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.
61
+ - **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
62
+ - **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.
63
+ - **Your storage.** Plug in any backend with two functions, or use the built-in memory and localStorage adapters. No hosted service is needed.
64
+ - **Assets and fonts.** Documents record the images and fonts they need. When a document is opened, every image and font is checked first. You get the exact list of what is missing, can offer replacements, and tab-only images are uploaded when you save.
65
+ - **Recovery.** Unsaved work is copied to IndexedDB while the user edits, and again at the moment the tab is closed or refreshed. After a crash or refresh you can offer to restore it, including images that only existed in the old tab.
66
+ - **Export.** PNG, JPEG, WebP, SVG and editable JSON. You choose the area, scale and background. A preflight check means an export either succeeds or tells you exactly which image or font prevents it.
67
+ - **Versions and migration.** Keep named versions, restore any of them as a new revision, and open plain Fabric JSON or documents saved by older versions of this package.
68
+ - **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.
69
+ - **React ready, framework free.** Hooks for React, and a small state store for any other framework.
70
+ - **Hardened.** Imported documents are cleaned and size-limited, undo history has a memory budget, and every feature is tested in Chromium, Firefox and WebKit.
71
+
72
+ ## React, Next.js, Vue and Svelte
73
+
74
+ A Fabric.js React example with a toolbar that shows undo and save state:
46
75
 
47
76
  ```tsx
48
77
  import { useDocumentEngine, useDocumentState, DocumentEngineProvider, useEngine } from 'fabricjs-document-engine/react';
@@ -74,11 +103,13 @@ function YourToolbar() {
74
103
  - `useDocumentState(engine)` returns `{ documentId, isLoading, loadError, saveStatus, isDirty, isSaving, revision, lastSavedAt, saveError, canUndo, canRedo, undoLabel, redoLabel, assetWarnings }` and re-renders when any of them change.
75
104
  - `useDocumentEvent(engine, 'save:error', handler)` subscribes to any event with the latest handler.
76
105
  - `DocumentEngineProvider` and `useEngine()` pass the engine to deeply nested toolbars.
77
- - The React entry is marked `'use client'` for Next.js. React is an optional peer dependency, so the core never imports it.
106
+ - The React entry is marked `'use client'`. For Fabric.js in Next.js, render the editor in a client component loaded with a dynamic import that skips the server, because Fabric needs `window`. React is an optional peer dependency, and the core never imports it.
78
107
 
79
- For other frameworks, `createDocumentStateStore(engine)` gives the same state as `{ getSnapshot, subscribe }`, which fits Svelte stores, Vue's `shallowRef` and similar tools.
108
+ For other frameworks, `createDocumentStateStore(engine)` gives the same state as `{ getSnapshot, subscribe }`. It fits Svelte stores, Vue's `shallowRef` and similar tools.
80
109
 
81
- ## Saving and loading through storage
110
+ Framework guides: [React](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/react) · [Next.js](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/next-js) · [Fabric.js with Vue 3](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/vue) (keep the canvas out of deep reactivity with `toRaw`) · [Fabric.js with Svelte](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/svelte) · [Plain JavaScript](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/vanilla-js)
111
+
112
+ ## Save and load from a database or API
82
113
 
83
114
  The built-in adapters are the quickest way to start:
84
115
 
@@ -133,7 +164,9 @@ const storage: DocumentStorage = {
133
164
  - Throw an error with `retryable: false` for failures that retrying cannot fix. Every other error is retried.
134
165
  - Pass `signal` to `fetch`. The engine aborts it when another document is opened.
135
166
 
136
- ## Safe saving
167
+ ## Autosave and save conflicts
168
+
169
+ Fabric.js autosave is one option. The rest of this section is about what happens when saves go wrong, because that is where editors lose work.
137
170
 
138
171
  ```ts
139
172
  const engine = createDocumentEngine({
@@ -166,7 +199,9 @@ import { bindUnsavedChangesWarning } from 'fabricjs-document-engine';
166
199
  const unbind = bindUnsavedChangesWarning(engine);
167
200
  ```
168
201
 
169
- ## Assets and fonts
202
+ Full guides: [autosave](https://fabricjs-document-engine.jscrate.dev/docs/guides/autosave) and [save conflicts](https://fabricjs-document-engine.jscrate.dev/docs/guides/save-conflicts).
203
+
204
+ ## Images, fonts and CORS
170
205
 
171
206
  Every saved document carries an `assets` manifest that lists each image URL and font variant, together with the ids of the objects that use them. Images embedded as `data:` URLs are left out of the manifest because they need no fetching.
172
207
 
@@ -196,7 +231,7 @@ engine.on('assets:warning', ({ warnings }) => warnings.forEach((warning) => cons
196
231
  3. Every image loads in parallel. If any are missing, `replaceMissingImage` can supply a replacement URL for each one. Return `null` to leave it missing.
197
232
  4. If images are still missing, loading fails with `MISSING_ASSETS`, and `error.missingAssets` lists each `{ url, objectIds }`. The canvas is not touched.
198
233
 
199
- Fonts that are not available produce a `FONT_UNAVAILABLE` warning and the text uses a fallback font. Set `requireFonts: true` to fail with `MISSING_FONTS` instead. Warnings are also delivered with `load:success` as `{ document, warnings }`.
234
+ Fabric.js fonts that are not available produce a `FONT_UNAVAILABLE` warning, and the text uses a fallback font. Set `requireFonts: true` to fail with `MISSING_FONTS` instead. Warnings are also delivered with `load:success` as `{ document, warnings }`.
200
235
 
201
236
  ### When a document is saved
202
237
 
@@ -204,7 +239,7 @@ Images that exist only in this tab (`blob:` URLs) and embedded `data:` images ar
204
239
 
205
240
  ### Cross-origin images
206
241
 
207
- An image from another site without `crossOrigin: 'anonymous'` taints the canvas, and exporting it will fail. The engine warns with `IMAGE_CROSS_ORIGIN` so you can fix it before the user tries to export.
242
+ A Fabric.js CORS image problem is the most common reason an export fails. An image from another site without `crossOrigin: 'anonymous'` taints the canvas, and exporting it will fail. The engine warns with `IMAGE_CROSS_ORIGIN` so you can fix it before the user tries to export.
208
243
 
209
244
  ### Checking and replacing at any time
210
245
 
@@ -219,7 +254,9 @@ await engine.replaceImage('/old-logo.png', '/new-logo.png');
219
254
 
220
255
  `replaceImage` swaps every image that uses a URL. Each image keeps its size on the page, and the change is one undo step. `engine.getAssetManifest()` returns the manifest for the current canvas.
221
256
 
222
- ## Export
257
+ ## Export an image, SVG or JSON
258
+
259
+ Fabric.js export to PNG, JPEG, WebP or SVG goes through one call. The result is a `Blob` you can download or upload.
223
260
 
224
261
  ```ts
225
262
  import { downloadExport } from 'fabricjs-document-engine';
@@ -244,6 +281,7 @@ downloadExport(result, 'poster.png');
244
281
  - A JPEG has no transparency, so an empty or transparent background becomes white instead of black.
245
282
  - The export never changes the canvas, the history or the unsaved state.
246
283
  - A JSON export is the same portable document a save produces, including uploaded images when `assets.upload` is set.
284
+ - There is no PDF export. Pass the PNG or SVG result to a PDF library if you need one.
247
285
 
248
286
  ### Preflight and errors
249
287
 
@@ -260,7 +298,7 @@ const check = await engine.preflightExport({ format: 'png' });
260
298
  if (!check.ok) showProblems(check.problems);
261
299
  ```
262
300
 
263
- ## Versions
301
+ ## Version history
264
302
 
265
303
  ```ts
266
304
  const version = await engine.createVersion('Sent to client');
@@ -275,7 +313,7 @@ await engine.deleteVersion(version.id);
275
313
  - **Automatic versions.** Use `versions: { autoEvery: 10, keepAuto: 20 }` to keep a version after every 10 successful saves. Named versions are never pruned. Only the newest `keepAuto` automatic versions are kept, 20 by default.
276
314
  - Undo and redo cover recent edits in this session. Versions preserve chosen states for later.
277
315
 
278
- ## Migration and importing Fabric JSON
316
+ ## Load from JSON and migrate from Fabric 5
279
317
 
280
318
  Plain Fabric JSON, such as the output of `canvas.toJSON()` from Fabric 5, 6 or 7, opens directly:
281
319
 
@@ -285,9 +323,11 @@ await engine.importFabricJson(savedJsonText, { id: 'plan-42', metadata: { source
285
323
 
286
324
  `loadDocument` and `load(id)` also recognise plain Fabric JSON, so projects stored by an existing Fabric app open without a separate import step. A document loaded with `load(id)` keeps that id, and its next save stores it in the current format.
287
325
 
288
- Every document records its `schemaVersion`. When the package format changes, older documents are upgraded step by step when they are opened. `load:success` reports `migratedFrom` when that happened. A failed step rejects with `MIGRATION_FAILED`, and `error.migrationFrom` names the version it started from. A document from a newer version of the package is refused with `UNSUPPORTED_SCHEMA` rather than being misread. `migrateDocument(value, context)` and `detectSchemaVersion(value)` are exported for tooling such as server-side batch upgrades.
326
+ Every document records its `schemaVersion`, which makes Fabric.js migration a one-way, step-by-step upgrade. When the package format changes, older documents are upgraded when they are opened. `load:success` reports `migratedFrom` when that happened. A failed step rejects with `MIGRATION_FAILED`, and `error.migrationFrom` names the version it started from. A document from a newer version of the package is refused with `UNSUPPORTED_SCHEMA` rather than being misread. `migrateDocument(value, context)` and `detectSchemaVersion(value)` are exported for tooling such as server-side batch upgrades.
327
+
328
+ ## Recover unsaved work
289
329
 
290
- ## Recovery
330
+ Fabric.js IndexedDB recovery runs in the background while the user edits:
291
331
 
292
332
  ```ts
293
333
  import { createIndexedDbRecovery } from 'fabricjs-document-engine/recovery';
@@ -315,7 +355,9 @@ if (latest && confirm(`Restore unsaved work from ${new Date(latest.savedAt).toLo
315
355
  - `engine.flushRecovery()` writes a copy right now. `engine.getRecovery(id?)` reads one.
316
356
  - `createMemoryRecovery()` keeps copies in memory, which is useful for tests. To use your own storage, implement `{ get, set, delete, keys }`, plus an optional synchronous `setNow` for the moment the page closes.
317
357
 
318
- ## Custom objects
358
+ ## Custom objects and properties
359
+
360
+ A Fabric.js custom object keeps its extra fields only if something lists them at save time. Register the class once and its properties survive every save, load, undo and redo:
319
361
 
320
362
  ```ts
321
363
  import { Rect } from 'fabric';
@@ -333,7 +375,7 @@ const engine = createDocumentEngine({
333
375
 
334
376
  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.
335
377
 
336
- ## Undo and redo
378
+ ## Fabric.js undo and redo
337
379
 
338
380
  History is on by default. The engine records these automatically:
339
381
 
@@ -358,7 +400,7 @@ await engine.redo();
358
400
  ```
359
401
 
360
402
  - Transactions can be nested, and the outermost label is used. They can also be async: `await engine.transaction('Import', async () => { ... })`.
361
- - 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.
403
+ - To group objects or ungroup them, do the remove and the add inside one transaction, and they take one undo step. Grouping is an ordinary change to the object list.
362
404
  - 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.
363
405
  - Keep the last 50 steps with `createDocumentEngine({ canvas, history: { limit: 50 } })`. The default is 100.
364
406
 
@@ -381,6 +423,8 @@ engine.on('history:change', ({ canUndo, canRedo, undoLabel, redoLabel }) => {
381
423
  });
382
424
  ```
383
425
 
426
+ The [undo and redo guide](https://fabricjs-document-engine.jscrate.dev/docs/guides/undo-redo) has a live demo and covers text editing in more detail.
427
+
384
428
  ## Document format
385
429
 
386
430
  ```ts
@@ -409,6 +453,24 @@ The format is described by a JSON Schema that ships with the package:
409
453
  import schema from 'fabricjs-document-engine/schema/document-v1.json';
410
454
  ```
411
455
 
456
+ ## Where it fits
457
+
458
+ Use it when you are building a Fabric.js canvas editor: a design editor, an image editor, a floor planner, a label or certificate builder. Fabric still does the drawing, selection and serialization. This package adds the document layer on top: ids, history, saving, loading, assets, export and recovery.
459
+
460
+ If you are comparing a canvas editor JS library or an undo redo JavaScript library, note the scope. It does not draw a toolbar, and it does not do real-time collaboration. The [comparison page](https://fabricjs-document-engine.jscrate.dev/docs/overview/comparison) sets it next to `fabric-history`, `fabricjs-react` and hand-written `toJSON`.
461
+
462
+ ## Compatibility
463
+
464
+ | | Supported |
465
+ | --- | --- |
466
+ | Fabric | Fabric.js 6 and Fabric.js 7 (peer `^6.0.0 \|\| ^7.0.0`); plain JSON from Fabric 5 opens through migration |
467
+ | Browsers | Full suite passes in Chromium, Firefox and WebKit |
468
+ | React | 18 and 19, optional |
469
+ | Node | 18 or later, for server-side import, validation and migration |
470
+ | Modules | ESM and CommonJS, with TypeScript types |
471
+
472
+ Tested versions and performance numbers (5,000 objects, every step under 50 ms except load) are in [docs/compatibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility.md).
473
+
412
474
  ## API reference
413
475
 
414
476
  Every function, option, event and error code is listed in [docs/api.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/api.md). Every failure is a `DocumentEngineError` with a stable `code` you can switch on, such as `SAVE_CONFLICT`, `MISSING_ASSETS` or `UNSAVED_CHANGES`. A failed load never clears or half-fills your canvas.
@@ -421,7 +483,7 @@ Version 1.0 freezes the document format and the adapter contracts:
421
483
  - Public API names, options, events and error codes do not change within 1.x. New ones may be added.
422
484
  - Storage, version and recovery adapters written for 1.0 keep working. `verifyStorageAdapter(storage)` from `fabricjs-document-engine/storage` checks that your adapter follows the save rules.
423
485
 
424
- The full promise is in the [compatibility policy](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility-policy.md). Tested browsers, Fabric versions and performance results are in [docs/compatibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility.md), and a complete setup is in the [production guide](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/production.md).
486
+ The full promise is in the [compatibility policy](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility-policy.md). A complete setup is in the [production guide](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/production.md).
425
487
 
426
488
  ## Imported content and limits
427
489
 
@@ -449,7 +511,15 @@ Accessibility guidance for your toolbar, status text and dialogs is in [docs/acc
449
511
 
450
512
  ## Troubleshooting
451
513
 
452
- Common problems and fixes are in [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md).
514
+ Common problems and fixes are in [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md). Questions people ask most, such as why `loadFromJSON` loses custom properties, are answered in the [FAQ](https://fabricjs-document-engine.jscrate.dev/docs/overview/faq).
515
+
516
+ ## Help and contributing
517
+
518
+ - Documentation and live Fabric.js examples: [fabricjs-document-engine.jscrate.dev](https://fabricjs-document-engine.jscrate.dev)
519
+ - Bugs and feature requests: [GitHub issues](https://github.com/re-sohail/fabricjs-document-engine/issues)
520
+ - Release notes: [CHANGELOG.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/CHANGELOG.md)
521
+
522
+ Maintained by [Sohail Khan](https://me.jscrate.dev). Pull requests are welcome. Every user-facing change needs a changeset (`npx changeset`).
453
523
 
454
524
  ## License
455
525
 
@@ -0,0 +1,526 @@
1
+ # fabricjs-document-engine
2
+
3
+ 为现有的 [Fabric.js](https://fabricjs.com) 画布提供保存、加载、撤销和重做。对象的 id 始终不变,慢的旧保存也不会覆盖新的改动。
4
+
5
+ [![npm version](https://img.shields.io/npm/v/fabricjs-document-engine.svg)](https://www.npmjs.com/package/fabricjs-document-engine)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/fabricjs-document-engine)](https://bundlephobia.com/package/fabricjs-document-engine)
7
+ [![types](https://img.shields.io/npm/types/fabricjs-document-engine.svg)](https://www.npmjs.com/package/fabricjs-document-engine)
8
+ [![license](https://img.shields.io/npm/l/fabricjs-document-engine.svg)](https://github.com/re-sohail/fabricjs-document-engine/blob/main/LICENSE)
9
+
10
+ [中文文档](https://fabricjs-document-engine.jscrate.dev/zh) · [在线示例](https://fabricjs-document-engine.jscrate.dev/zh#examples-heading) · [fabric.js 使用教程](https://fabricjs-document-engine.jscrate.dev/zh/docs/overview/tutorial) · [API](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/api.md) · [English](https://github.com/re-sohail/fabricjs-document-engine/blob/main/README.md)
11
+
12
+ 画布、工具栏和界面都由你自己掌控。这个包在旁边工作,把画布上的内容变成一份可以保存、重新打开、继续编辑的文档。它支持 Fabric 6 和 7,可以用在 React、Next.js、Vue、Svelte 或原生 JavaScript 中。
13
+
14
+ ## 为什么需要它
15
+
16
+ 做过 fabricjs 编辑器的人,基本都踩过同样的坑。Fabric.js 的序列化和绘制都做得很好,但一份文档需要的不止这些:
17
+
18
+ - **没有撤销功能。** Fabric 没有内置历史记录,每个团队都得自己写撤销和重做(上一步/下一步),而且往往在这个过程中丢掉对象引用([fabric.js#10011](https://github.com/fabricjs/fabric.js/issues/10011))。
19
+ - **自定义属性会丢失。** `toJSON` 和 `loadFromJSON` 会丢掉 Fabric 不认识的字段,除非你每次调用都把它们列出来([fabric.js#10887](https://github.com/fabricjs/fabric.js/issues/10887))。
20
+ - **加载后找不到对象。** Fabric 不给对象分配稳定的 id,所以加载之后没法按 id 获取对象。编组里的子对象更是完全没有 id。
21
+ - **保存互相冲突。** 旧的请求可能最后才返回,覆盖掉新的改动;另一个标签页也可能覆盖这一个的保存。
22
+
23
+ 这个包解决这四个问题,以及它们背后的问题:图片缺失、字体加载失败、标签页崩溃和旧的文件格式。
24
+
25
+ ## 安装
26
+
27
+ 从 npm 安装,同时安装 Fabric:
28
+
29
+ ```bash
30
+ npm install fabricjs-document-engine fabric
31
+ ```
32
+
33
+ 这个包用 TypeScript 编写,自带类型定义。它没有运行时依赖。`fabric` 是 peer dependency,只有使用 hooks 时才需要 React。
34
+
35
+ ## 快速开始
36
+
37
+ 把 Fabric.js 画布保存为 JSON,再加载回来(回显):
38
+
39
+ ```ts
40
+ import { Canvas, Rect } from 'fabric';
41
+ import { createDocumentEngine } from 'fabricjs-document-engine';
42
+
43
+ const canvas = new Canvas('editor', { width: 800, height: 600 });
44
+ const engine = createDocumentEngine({ canvas });
45
+
46
+ canvas.add(new Rect({ width: 100, height: 80, fill: 'tomato' }));
47
+
48
+ const document = engine.toDocument();
49
+ localStorage.setItem(document.id, JSON.stringify(document));
50
+
51
+ await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
52
+ ```
53
+
54
+ 第一次试用,这些就够了。这里用 localStorage 没问题;在真实应用中,你会传入一个存储适配器,让自动保存来完成工作,下文会讲到。[快速开始指南](https://fabricjs-document-engine.jscrate.dev/zh/docs/overview/quick-start)会一步步带你完成。
55
+
56
+ ## 它能做什么
57
+
58
+ - **稳定的对象 id。** 每个对象,包括编组里的子对象,都有一个 id,移动、改样式、编组、保存和重新打开后都不会变。`engine.getObjectById(id)` 可以再次找到它。
59
+ - **带版本号的文档格式。** 它记录 schema 版本、画布尺寸、背景、对象顺序和你自己的元数据。
60
+ - **安全加载。** 文档会先经过校验。未知的对象类型会在动画布之前被拒绝。图片缺失会让加载失败,而不是悄悄消失。多次加载重叠时,以最新的一次为准。
61
+ - **自定义对象。** 注册你自己的 Fabric 类,以及它们需要保留的额外属性。
62
+ - **安全保存。** 它会跟踪未保存的改动,也可以自动保存。同一时间只运行一次保存,所以慢的旧保存永远不会覆盖新的改动。修订号检查能发现另一个标签页或设备保存了同一份文档,失败的保存会按退避策略重试。
63
+ - **你自己的存储。** 用两个函数接入任意后端,或者使用内置的内存和 localStorage 适配器。不需要任何托管服务。
64
+ - **图片和字体。** 文档会记录它需要的图片和字体。打开文档时,会先检查每张图片和每种字体。你会拿到缺失内容的准确列表,可以提供替换,只存在于当前标签页的图片会在保存时上传。
65
+ - **恢复。** 用户编辑时,未保存的内容会被复制到 IndexedDB;关闭或刷新标签页的那一刻还会再复制一次。崩溃或刷新之后,你可以提示用户恢复,包括只存在于旧标签页中的图片。
66
+ - **导出。** 支持 PNG、JPEG、WebP、SVG 和可编辑的 JSON。区域、缩放和背景由你选择。导出前的预检意味着导出要么成功,要么准确告诉你是哪张图片或哪种字体导致失败。
67
+ - **版本和迁移。** 保存命名版本,把任意版本恢复为新的修订,还能打开纯 Fabric JSON 或旧版本这个包保存的文档。
68
+ - **撤销和重做。** 用户的一次操作就是一步撤销。事务可以把代码里的多处改动合成一个带标签的步骤,撤销和重做后 id 保持不变。
69
+ - **支持 React,不绑定框架。** 为 React 提供 hooks,为其他框架提供一个小的状态 store。
70
+ - **经过加固。** 导入的文档会被清理并限制大小,撤销历史有内存上限,每个功能都在 Chromium、Firefox 和 WebKit 中测试过。
71
+
72
+ ## React、Next.js、Vue 和 Svelte
73
+
74
+ 一个 Fabric.js React 示例,工具栏显示撤销和保存状态:
75
+
76
+ ```tsx
77
+ import { useDocumentEngine, useDocumentState, DocumentEngineProvider, useEngine } from 'fabricjs-document-engine/react';
78
+
79
+ function Editor({ canvas }: { canvas: Canvas | null }) {
80
+ const engine = useDocumentEngine(canvas, { storage, autosave: true });
81
+ return (
82
+ <DocumentEngineProvider engine={engine}>
83
+ <YourToolbar />
84
+ </DocumentEngineProvider>
85
+ );
86
+ }
87
+
88
+ function YourToolbar() {
89
+ const engine = useEngine();
90
+ const state = useDocumentState(engine);
91
+ if (!engine || !state) return null;
92
+ return (
93
+ <>
94
+ <button disabled={!state.canUndo} onClick={() => engine.undo()}>Undo {state.undoLabel}</button>
95
+ <button disabled={!state.isDirty} onClick={() => engine.save()}>Save</button>
96
+ <span>{state.saveStatus}</span>
97
+ </>
98
+ );
99
+ }
100
+ ```
101
+
102
+ - `useDocumentEngine(canvas, options)` 在 Fabric 画布创建后创建引擎,并在组件卸载时销毁它。在那之前返回 `null`。选项只在创建引擎时读取。
103
+ - `useDocumentState(engine)` 返回 `{ documentId, isLoading, loadError, saveStatus, isDirty, isSaving, revision, lastSavedAt, saveError, canUndo, canRedo, undoLabel, redoLabel, assetWarnings }`,其中任意一项变化都会重新渲染。
104
+ - `useDocumentEvent(engine, 'save:error', handler)` 用最新的 handler 订阅任意事件。
105
+ - `DocumentEngineProvider` 和 `useEngine()` 把引擎传给嵌套很深的工具栏。
106
+ - React 入口标记了 `'use client'`。在 Next.js 中使用 Fabric.js 时,要在客户端组件中渲染编辑器,并用跳过服务端的动态导入加载它,因为 Fabric 需要 `window`。React 是可选的 peer dependency,核心代码从不导入它。
107
+
108
+ 对于其他框架,`createDocumentStateStore(engine)` 以 `{ getSnapshot, subscribe }` 的形式提供同样的状态,适用于 Svelte store、Vue 的 `shallowRef` 等工具。
109
+
110
+ 框架指南:[React](https://fabricjs-document-engine.jscrate.dev/zh/docs/frameworks/react) · [Next.js](https://fabricjs-document-engine.jscrate.dev/zh/docs/frameworks/next-js) · [Fabric.js 与 Vue 3](https://fabricjs-document-engine.jscrate.dev/zh/docs/frameworks/vue)(用 `toRaw` 让画布避开深层响应式) · [Fabric.js 与 Svelte](https://fabricjs-document-engine.jscrate.dev/zh/docs/frameworks/svelte) · [原生 JavaScript](https://fabricjs-document-engine.jscrate.dev/zh/docs/frameworks/vanilla-js)
111
+
112
+ ## 保存到数据库或 API,并从中加载
113
+
114
+ 内置适配器是最快的起步方式:
115
+
116
+ ```ts
117
+ import { createLocalStorage, createMemoryStorage } from 'fabricjs-document-engine/storage';
118
+
119
+ const engine = createDocumentEngine({
120
+ canvas,
121
+ storage: createLocalStorage({ prefix: 'my-app:' }),
122
+ autosave: true,
123
+ });
124
+
125
+ await engine.load('project-42');
126
+ await engine.save();
127
+ ```
128
+
129
+ 两个适配器都提供 `listDocuments()` 和 `deleteDocument(id)`。要基于其他键值存储构建,使用 `createKeyValueStorage({ read, write, remove, keys }, prefix)`。
130
+
131
+ 更多适配器,包括带修订号检查和图片上传的 REST API,见 [docs/storage-examples.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/storage-examples.md)。
132
+
133
+ ### 你自己的后端
134
+
135
+ 一个存储适配器就是两个函数:
136
+
137
+ ```ts
138
+ import { createDocumentEngine, createConflictError } from 'fabricjs-document-engine';
139
+ import type { DocumentStorage } from 'fabricjs-document-engine';
140
+
141
+ const storage: DocumentStorage = {
142
+ async loadDocument(id) {
143
+ const response = await fetch(`/api/documents/${id}`);
144
+ return response.json();
145
+ },
146
+ async saveDocument(document, { expectedRevision, signal }) {
147
+ const response = await fetch(`/api/documents/${document.id}`, {
148
+ method: 'PUT',
149
+ headers: { 'If-Match': String(expectedRevision ?? '*') },
150
+ body: JSON.stringify(document),
151
+ signal,
152
+ });
153
+ if (response.status === 409) throw Object.assign(new Error('Saved elsewhere'), { code: 'SAVE_CONFLICT' });
154
+ if (response.status === 403) throw Object.assign(new Error('Not allowed'), { retryable: false });
155
+ if (!response.ok) throw new Error(`Save failed with ${response.status}`);
156
+ return { revision: document.revision };
157
+ },
158
+ };
159
+ ```
160
+
161
+ - `expectedRevision` 是这个编辑器上次保存或加载时的修订号。当存储中的文档修订号不同时,拒绝这次保存。用户选择覆盖时,它是 `null`。
162
+ - `document.revision` 是下一个修订号。如果你的后端自己分配编号,返回 `{ revision }`。
163
+ - 抛出带 `code: 'SAVE_CONFLICT'` 的错误,或使用 `createConflictError(id, expected, actual)`,来报告冲突。冲突永远不会重试。
164
+ - 对于重试也无法解决的失败,抛出带 `retryable: false` 的错误。其他错误都会重试。
165
+ - 把 `signal` 传给 `fetch`。打开另一份文档时,引擎会中止它。
166
+
167
+ ## 自动保存和保存冲突
168
+
169
+ Fabric.js 自动保存只是一个选项。本节主要讲保存出错时会发生什么,因为编辑器正是在这里丢失内容的。
170
+
171
+ ```ts
172
+ const engine = createDocumentEngine({
173
+ canvas,
174
+ storage,
175
+ autosave: { delay: 1000, maxWait: 10000 },
176
+ saveRetry: { attempts: 3, baseDelay: 500, maxDelay: 8000 },
177
+ });
178
+
179
+ engine.on('save:status', ({ status, isDirty, revision, lastSavedAt, error }) => {
180
+ statusLabel.textContent = status;
181
+ });
182
+ ```
183
+
184
+ `status` 是 `saved`、`unsaved`、`saving`、`error` 或 `conflict` 之一。
185
+
186
+ - **未保存的改动。** 每一步记录下来的历史、撤销、重做和元数据修改,都会把文档标记为已修改。`engine.isDirty()` 告诉你是否有未保存的内容。保存进行时做的修改,会一直保持未保存状态,直到下一次保存。
187
+ - **自动保存。** 停止编辑 `delay` 毫秒后保存;即使用户一直在编辑,最迟也会在第一次未保存修改后的 `maxWait` 毫秒保存。`autosave: true` 使用上面示例中的默认值。
188
+ - **同一时间只有一次保存。** 保存进行中再调用 `save()`,只会排队一次后续保存,保存的是最新内容。响应永远不会乱序到达。
189
+ - **过期的响应。** 保存进行中如果加载了另一份文档,这次保存的响应会被忽略,排队中的保存会以 `SAVE_CANCELLED` 取消。
190
+ - **冲突。** 当另一个标签页或设备先保存时,保存会以 `SAVE_CONFLICT` 失败,状态变为 `conflict`。你可以用 `engine.load(id)` 重新加载文档,或者用 `engine.save({ overwrite: true })` 保留你的版本。
191
+ - **重试。** 临时失败会按指数退避加随机抖动重试。每次重试都会触发 `save:retry`,带有 `{ attempt, delay, error }`。
192
+ - **未保存的内容不会被悄悄替换。** 使用存储适配器时,只要有未保存的改动,`load`、`loadDocument`、`importFabricJson` 和 `newDocument` 都会以 `UNSAVED_CHANGES` 拒绝。先保存,或者在用户选择丢弃改动时传入 `{ discardUnsavedChanges: true }`。
193
+
194
+ ### 离开前提醒
195
+
196
+ ```ts
197
+ import { bindUnsavedChangesWarning } from 'fabricjs-document-engine';
198
+
199
+ const unbind = bindUnsavedChangesWarning(engine);
200
+ ```
201
+
202
+ 完整指南:[自动保存](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/autosave)和[保存冲突](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/save-conflicts)。
203
+
204
+ ## 图片、字体和 CORS
205
+
206
+ 每份保存的文档都带有一个 `assets` 清单,列出每个图片 URL 和字体变体,以及使用它们的对象 id。以 `data:` URL 内嵌的图片不需要请求,所以不在清单中。
207
+
208
+ ```ts
209
+ const engine = createDocumentEngine({
210
+ canvas,
211
+ storage,
212
+ assets: {
213
+ resolveUrl: (url) => url.replace('asset://', 'https://cdn.example.com/'),
214
+ replaceMissingImage: (image) => '/placeholder.png',
215
+ upload: async ({ blob }) => uploadToYourBucket(blob),
216
+ loadFont: async ({ family, weight, style }) => {
217
+ const face = new FontFace(family, `url(/fonts/${family}-${weight}.woff2)`, { weight, style });
218
+ document.fonts.add(await face.load());
219
+ },
220
+ requireFonts: false,
221
+ },
222
+ });
223
+
224
+ engine.on('assets:warning', ({ warnings }) => warnings.forEach((warning) => console.warn(warning.message)));
225
+ ```
226
+
227
+ ### 打开文档时
228
+
229
+ 1. `resolveUrl` 可以改写每个存储的 URL,例如给它签名,或者把资源 id 映射到 CDN。
230
+ 2. `loadFont` 对每个字体变体运行一次。之后引擎会检查字体是否真的能渲染,而不是悄悄回退到默认字体。
231
+ 3. 所有图片并行加载。如果有缺失,`replaceMissingImage` 可以为每一张提供替换 URL。返回 `null` 则保持缺失。
232
+ 4. 如果仍有图片缺失,加载会以 `MISSING_ASSETS` 失败,`error.missingAssets` 列出每个 `{ url, objectIds }`。画布不会被改动。
233
+
234
+ 不可用的 Fabric.js 字体会产生 `FONT_UNAVAILABLE` 警告,文字使用后备字体。设置 `requireFonts: true` 则改为以 `MISSING_FONTS` 失败。警告也会随 `load:success` 以 `{ document, warnings }` 的形式传递。
235
+
236
+ ### 保存文档时
237
+
238
+ 只存在于当前标签页的图片(`blob:` URL)和内嵌的 `data:` 图片,会各传给 `upload` 一次,文档中保存返回的 URL。没有 `upload` 处理函数时,`blob:` 图片会产生 `ASSET_NOT_PORTABLE` 警告,因为其他设备无法打开它们。
239
+
240
+ ### 跨域图片
241
+
242
+ Fabric.js 的 CORS 图片问题是导出失败最常见的原因。来自其他站点、没有设置 `crossOrigin: 'anonymous'` 的图片会污染画布,导出就会失败。引擎会以 `IMAGE_CROSS_ORIGIN` 发出警告,让你在用户导出之前修复它。
243
+
244
+ ### 随时检查和替换
245
+
246
+ ```ts
247
+ const report = await engine.checkAssets();
248
+ report.missingImages;
249
+ report.unavailableFonts;
250
+ report.warnings;
251
+
252
+ await engine.replaceImage('/old-logo.png', '/new-logo.png');
253
+ ```
254
+
255
+ `replaceImage` 会替换所有使用某个 URL 的图片。每张图片在页面上的尺寸保持不变,这次修改算一步撤销。`engine.getAssetManifest()` 返回当前画布的清单。
256
+
257
+ ## 导出图片、SVG 或 JSON
258
+
259
+ Fabric.js 导出 PNG、JPEG、WebP 或 SVG(fabricjs 导出 svg、导出图片),一次调用就能完成。结果是一个 `Blob`,可以下载或上传。
260
+
261
+ ```ts
262
+ import { downloadExport } from 'fabricjs-document-engine';
263
+
264
+ const result = await engine.export({ format: 'png', scale: 2 });
265
+ downloadExport(result, 'poster.png');
266
+ ```
267
+
268
+ `result` 是 `{ format, mimeType, blob, width, height, warnings }`。JSON 导出还包含 `document`。
269
+
270
+ | 选项 | 取值 | 默认值 |
271
+ | --- | --- | --- |
272
+ | `format` | `'png'`、`'jpeg'`、`'webp'`、`'svg'` 或 `'json'` | 必填 |
273
+ | `scale` | 输出尺寸倍数,例如 `2` 用于高分屏 | `1` |
274
+ | `quality` | 0 到 1,用于 JPEG 和 WebP | `0.92` |
275
+ | `area` | `'canvas'`、`'content'`(所有对象)、`'selection'`,或 `{ left, top, width, height }` | `'canvas'` |
276
+ | `padding` | `content` 或 `selection` 周围的额外空白 | `0` |
277
+ | `background` | `'keep'`、`'transparent'` 或任意 CSS 颜色 | `'keep'` |
278
+ | `signal` | 用于取消的 `AbortSignal` | |
279
+
280
+ - 当前的缩放和平移不影响结果。导出始终使用文档坐标,之后会恢复视图。
281
+ - JPEG 没有透明通道,所以空的或透明的背景会变成白色,而不是黑色。
282
+ - 导出不会改变画布、历史记录或未保存状态。
283
+ - JSON 导出就是保存时生成的那份可移植文档;设置了 `assets.upload` 时,也包括上传后的图片。
284
+ - 不支持导出 PDF。如果需要,把 PNG 或 SVG 结果交给 PDF 库处理。
285
+
286
+ ### 预检和错误
287
+
288
+ 渲染之前,引擎会检查画布上的对象:
289
+
290
+ - **`MISSING_IMAGE`**:某张图片加载失败。
291
+ - **`CROSS_ORIGIN_IMAGE`**:来自其他站点、没有 CORS 的图片会让浏览器阻止 PNG、JPEG 或 WebP 导出。SVG 和 JSON 不受影响。
292
+ - **`MISSING_FONT`**:某种字体不可用,且开启了 `assets.requireFonts`。否则你会得到 `FONT_UNAVAILABLE` 警告。
293
+
294
+ 只要发现问题,`export` 就会以 `EXPORT_BLOCKED` 拒绝,`error.problems` 列出每个 `{ code, message, url?, family?, objectIds }`。你可以先运行同样的检查,把结果显示在界面上:
295
+
296
+ ```ts
297
+ const check = await engine.preflightExport({ format: 'png' });
298
+ if (!check.ok) showProblems(check.problems);
299
+ ```
300
+
301
+ ## 版本历史
302
+
303
+ ```ts
304
+ const version = await engine.createVersion('Sent to client');
305
+ const versions = await engine.listVersions();
306
+ await engine.restoreVersion(version.id);
307
+ await engine.deleteVersion(version.id);
308
+ ```
309
+
310
+ - 版本是文档的完整副本,保存在你的存储适配器中。内置适配器都支持版本。自定义适配器需要增加四个方法:`saveVersion(version)`、`listVersions(documentId)`、`loadVersion(documentId, versionId)` 和 `deleteVersion(documentId, versionId)`。
311
+ - `listVersions` 按从新到旧返回摘要:`{ id, documentId, name, kind, createdAt, revision }`。`kind` 是 `named` 或 `auto`。
312
+ - **恢复永远不会丢失内容。** 引擎会先保留一个名为 `Before restoring "..."` 的自动版本,然后把旧内容作为同一份文档的一个新的、未保存的修订加载。下一次保存会把它存为最新修订,历史保持线性。要撤销一次恢复,恢复那个自动版本即可。
313
+ - **自动版本。** 使用 `versions: { autoEvery: 10, keepAuto: 20 }`,每成功保存 10 次保留一个版本。命名版本永远不会被清理。自动版本只保留最新的 `keepAuto` 个,默认 20 个。
314
+ - 撤销和重做覆盖本次会话中最近的编辑。版本则把选定的状态保留下来,供以后使用。
315
+
316
+ ## 加载 JSON 并从 Fabric 5 迁移
317
+
318
+ 纯 Fabric JSON,例如 Fabric 5、6 或 7 中 `canvas.toJSON()` 的输出,可以直接打开:
319
+
320
+ ```ts
321
+ await engine.importFabricJson(savedJsonText, { id: 'plan-42', metadata: { source: 'old editor' } });
322
+ ```
323
+
324
+ `loadDocument` 和 `load(id)` 也能识别纯 Fabric JSON,所以现有 Fabric 应用存储的项目不需要单独的导入步骤就能打开。用 `load(id)` 加载的文档会保留这个 id,下一次保存时以当前格式存储。
325
+
326
+ 每份文档都记录了自己的 `schemaVersion`,因此 Fabric.js 迁移是单向、逐步的升级。包的格式变化后,旧文档会在打开时升级。发生升级时,`load:success` 会报告 `migratedFrom`。某一步失败会以 `MIGRATION_FAILED` 拒绝,`error.migrationFrom` 指出它开始时的版本。来自更新版本的文档会以 `UNSUPPORTED_SCHEMA` 拒绝,而不会被误读。`migrateDocument(value, context)` 和 `detectSchemaVersion(value)` 也已导出,供服务端批量升级等工具使用。
327
+
328
+ ## 恢复未保存的内容
329
+
330
+ 用户编辑时,Fabric.js 的 IndexedDB 恢复在后台运行:
331
+
332
+ ```ts
333
+ import { createIndexedDbRecovery } from 'fabricjs-document-engine/recovery';
334
+
335
+ const engine = createDocumentEngine({
336
+ canvas,
337
+ storage,
338
+ recovery: { store: createIndexedDbRecovery(), interval: 2000 },
339
+ });
340
+
341
+ const [latest] = await engine.getRecoverableDocuments();
342
+ if (latest && confirm(`Restore unsaved work from ${new Date(latest.savedAt).toLocaleString()}?`)) {
343
+ await engine.restoreRecovery(latest.documentId);
344
+ } else if (latest) {
345
+ await engine.discardRecovery(latest.documentId);
346
+ }
347
+ ```
348
+
349
+ - **检查点。** 有未保存的改动时,最多每 `interval` 毫秒写一次副本(默认 2000)。文档已保存时不写入。
350
+ - **关闭或刷新。** 页面卸载时,浏览器不会让 IndexedDB 写完。所以标签页隐藏或关闭时,引擎还会立即往 localStorage 写一份副本。读取时以最新的副本为准。
351
+ - **只在当前标签页的图片。** `blob:` URL 的图片刷新后就没了。检查点会保留图片数据,恢复时为它们创建新的 URL。
352
+ - **保存之后。** 当一次保存覆盖了所有改动,副本会被删除。如果保存过程中标签页关闭了,副本会保留,所以从编辑到服务器之间的内容不会丢失。
353
+ - **恢复。** 恢复的文档会被标记为未保存,并保留它所基于的修订号。如果服务器在此期间有了更新,下一次保存会报告 `SAVE_CONFLICT`,而不是覆盖更新的内容。
354
+ - **中断的加载。** 文档加载时会保留一个标记。如果标签页在加载中崩溃,下次启动时 `engine.getInterruptedLoad()` 会返回 `{ documentId, startedAt }`,你可以跳过或丢弃那份文档,避免再次崩溃。
355
+ - `engine.flushRecovery()` 立即写一份副本。`engine.getRecovery(id?)` 读取一份。
356
+ - `createMemoryRecovery()` 把副本保存在内存中,适合测试。要使用你自己的存储,实现 `{ get, set, delete, keys }`,页面关闭时可以再加一个可选的同步方法 `setNow`。
357
+
358
+ ## 自定义对象和属性
359
+
360
+ Fabric.js 自定义对象的额外字段,只有在保存时被列出来才会保留,否则就会出现丢失自定义属性的问题。注册一次类,它的属性就能在每次保存、加载、撤销和重做中保留下来:
361
+
362
+ ```ts
363
+ import { Rect } from 'fabric';
364
+
365
+ class Sticker extends Rect {
366
+ static type = 'Sticker';
367
+ declare label: string;
368
+ }
369
+
370
+ const engine = createDocumentEngine({
371
+ canvas,
372
+ customObjects: [{ fabricClass: Sticker, properties: ['label'] }],
373
+ });
374
+ ```
375
+
376
+ 如果文档中包含未注册的类型,加载会以 `UNKNOWN_OBJECT_TYPE` 失败,并列出缺少的类型。你的对象永远不会被变成别的东西。
377
+
378
+ ## Fabric.js 撤销和重做
379
+
380
+ 历史记录默认开启。引擎会自动记录这些操作:
381
+
382
+ - 添加和删除对象,同一个 tick 内的多次改动合为一步
383
+ - 指针移动、缩放和旋转(Fabric 的 `object:modified`)
384
+ - 完成的文字编辑
385
+
386
+ 对于代码直接做的修改,例如 `object.set('fill', 'red')` 或 `canvas.bringObjectForward(object)`,Fabric 不会触发任何事件。把它们包在事务里,或者调用 `commit`:
387
+
388
+ ```ts
389
+ engine.transaction('Arrange furniture', () => {
390
+ chair.set({ left: 120, top: 80 });
391
+ table.set('fill', 'oak');
392
+ canvas.bringObjectToFront(table);
393
+ });
394
+
395
+ canvas.sendObjectBackwards(rug);
396
+ engine.commit('Send rug backwards');
397
+
398
+ await engine.undo();
399
+ await engine.redo();
400
+ ```
401
+
402
+ - 事务可以嵌套,使用最外层的标签。事务也可以是异步的:`await engine.transaction('Import', async () => { ... })`。
403
+ - 要编组或取消编组,把删除和添加放在同一个事务里,它们就只占一步撤销。编组只是对象列表的普通修改。
404
+ - 撤销和重做会根据保存的状态重建被修改的对象,所以它们会以新实例、相同 id 的形式回来。请用 `engine.getObjectById(id)` 重新获取,而不是保留旧的引用。
405
+ - 用 `createDocumentEngine({ canvas, history: { limit: 50 } })` 只保留最近 50 步。默认是 100。
406
+
407
+ ### 键盘快捷键
408
+
409
+ ```ts
410
+ import { bindKeyboardShortcuts } from 'fabricjs-document-engine';
411
+
412
+ const unbind = bindKeyboardShortcuts(engine);
413
+ ```
414
+
415
+ Ctrl/Cmd + Z 撤销。Ctrl/Cmd + Shift + Z 和 Ctrl + Y 重做。用户在 input、textarea、contenteditable 元素或 Fabric 文字中输入时,快捷键会被忽略,所以那里的原生文字撤销照常工作。传入 `{ target: element }` 可以监听 `window` 以外的元素。
416
+
417
+ ### 工具栏状态
418
+
419
+ ```ts
420
+ engine.on('history:change', ({ canUndo, canRedo, undoLabel, redoLabel }) => {
421
+ undoButton.disabled = !canUndo;
422
+ undoButton.title = undoLabel ? `Undo ${undoLabel}` : 'Undo';
423
+ });
424
+ ```
425
+
426
+ [撤销和重做指南](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/undo-redo)有在线示例,并更详细地介绍了文字编辑。
427
+
428
+ ## 文档格式
429
+
430
+ ```ts
431
+ interface FabricDocument {
432
+ schemaVersion: number;
433
+ id: string;
434
+ createdAt: string;
435
+ updatedAt: string;
436
+ revision?: number;
437
+ fabricVersion?: string;
438
+ canvas: { width: number; height: number; background?: unknown };
439
+ objects: SerializedFabricObject[];
440
+ assets?: {
441
+ images: Array<{ url: string; objectIds: string[] }>;
442
+ fonts: Array<{ family: string; weight: string; style: string; objectIds: string[] }>;
443
+ };
444
+ metadata: Record<string, unknown>;
445
+ }
446
+ ```
447
+
448
+ 标题、所有者、标签等项目数据,请用 `engine.updateMetadata()` 放在 `metadata` 中,而不是放在 Fabric 对象上。
449
+
450
+ 这个格式由包内附带的 JSON Schema 描述:
451
+
452
+ ```ts
453
+ import schema from 'fabricjs-document-engine/schema/document-v1.json';
454
+ ```
455
+
456
+ ## 适用场景
457
+
458
+ 当你在做 Fabric.js 画布编辑器时使用它:设计编辑器、图片编辑器、户型图工具、标签或证书生成器。绘制、选择和序列化仍然由 Fabric 完成。这个包在上面加了一层文档能力:id、历史记录、保存、加载、资源、导出和恢复。
459
+
460
+ 如果你在比较画布编辑器 JS 库或撤销重做 JavaScript 库,注意它的范围。它不画工具栏,也不做实时协作。[对比页面](https://fabricjs-document-engine.jscrate.dev/zh/docs/overview/comparison)把它和 `fabric-history`、`fabricjs-react` 以及手写的 `toJSON` 放在一起比较。
461
+
462
+ ## 兼容性
463
+
464
+ | | 支持情况 |
465
+ | --- | --- |
466
+ | Fabric | Fabric.js 6 和 Fabric.js 7(peer `^6.0.0 \|\| ^7.0.0`);Fabric 5 的纯 JSON 通过迁移打开 |
467
+ | 浏览器 | 完整测试在 Chromium、Firefox 和 WebKit 中通过 |
468
+ | React | 18 和 19,可选 |
469
+ | Node | 18 或更高,用于服务端导入、校验和迁移 |
470
+ | 模块 | ESM 和 CommonJS,带 TypeScript 类型 |
471
+
472
+ 测试过的版本和性能数据(5,000 个对象,除加载外每一步都在 50 ms 以内)见 [docs/compatibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility.md)。
473
+
474
+ ## API 参考
475
+
476
+ 每个函数、选项、事件和错误码都列在 [docs/api.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/api.md) 中。每个失败都是一个 `DocumentEngineError`,带有稳定的 `code`,可以直接用来判断,例如 `SAVE_CONFLICT`、`MISSING_ASSETS` 或 `UNSAVED_CHANGES`。加载失败永远不会清空画布,也不会只填一半。
477
+
478
+ ## 稳定性
479
+
480
+ 1.0 版冻结了文档格式和适配器约定:
481
+
482
+ - 文档由发布的 JSON Schema `fabricjs-document-engine/schema/document-v1.json` 校验。每个 1.x 版本都能读取之前版本写入的所有文档,以及 Fabric 5、6 和 7 的纯 Fabric JSON。
483
+ - 公开 API 的名称、选项、事件和错误码在 1.x 内不会改变,只可能新增。
484
+ - 为 1.0 编写的存储、版本和恢复适配器会继续可用。`fabricjs-document-engine/storage` 中的 `verifyStorageAdapter(storage)` 可以检查你的适配器是否遵守保存规则。
485
+
486
+ 完整承诺见[兼容性策略](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/compatibility-policy.md)。完整配置见[生产环境指南](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/production.md)。
487
+
488
+ ## 导入内容和限制
489
+
490
+ 文档经常来自用户,所以引擎把它们当作不可信的内容:
491
+
492
+ - 名为 `__proto__`、`constructor` 或 `prototype` 的键会在 Fabric 看到之前被删除。Fabric 会把每个键复制到它创建的对象上,否则这些键可能改变对象的原型。
493
+ - 图片地址在 `assets.resolveUrl` 之后、任何请求之前检查。允许 `http:`、`https:`、`blob:`、相对地址和 `data:image/...`。`javascript:`、`file:` 和非图片的 `data:` 地址会以 `UNSAFE_DOCUMENT` 拒绝。
494
+ - 超过 50,000 个对象、或嵌套超过 100 层的文档会在加载前被拒绝,恶意文件无法卡死标签页。
495
+
496
+ ```ts
497
+ createDocumentEngine({
498
+ canvas,
499
+ limits: {
500
+ maxObjects: 10_000,
501
+ maxDepth: 40,
502
+ isAllowedUrl: (url) => url.startsWith('https://cdn.example.com/'),
503
+ },
504
+ history: { limit: 100, maxBytes: 32 * 1024 * 1024 },
505
+ });
506
+ ```
507
+
508
+ `history.maxBytes` 限制撤销历史占用的内存。默认 64 MB,超出时先丢弃最早的步骤。SVG 导出会转义文字,所以文本框里的 `<script>` 这类内容仍然只是文字。
509
+
510
+ 关于工具栏、状态文字和对话框的无障碍建议,见 [docs/accessibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/accessibility.md)。
511
+
512
+ ## 问题排查
513
+
514
+ 常见问题和解决方法见 [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md)。大家问得最多的问题,例如 `loadFromJSON` 为什么会丢失自定义属性,在[常见问题](https://fabricjs-document-engine.jscrate.dev/zh/docs/overview/faq)中有解答。
515
+
516
+ ## 帮助和贡献
517
+
518
+ - 文档和 Fabric.js 在线示例:[fabricjs-document-engine.jscrate.dev](https://fabricjs-document-engine.jscrate.dev/zh)
519
+ - Bug 和功能建议:[GitHub issues](https://github.com/re-sohail/fabricjs-document-engine/issues)
520
+ - 更新日志:[CHANGELOG.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/CHANGELOG.md)
521
+
522
+ 由 [Sohail Khan](https://me.jscrate.dev) 维护。欢迎提交 Pull Request。每个面向用户的改动都需要一个 changeset(`npx changeset`)。
523
+
524
+ ## 许可证
525
+
526
+ MIT
@@ -167,12 +167,16 @@ function createDocumentEngine(options) {
167
167
  if (!storage || discardUnsavedChanges || !saving.state().isDirty) return;
168
168
  throw new require_errors.DocumentEngineError("UNSAVED_CHANGES", "The current document has unsaved changes. Save first, or pass { discardUnsavedChanges: true } to replace it anyway.");
169
169
  }
170
- async function loadMigratedDocument(input, loadOptions, importDetails) {
171
- ensureUsable();
172
- protectUnsavedChanges(loadOptions.discardUnsavedChanges);
170
+ function startLoad() {
173
171
  activeLoad?.abort();
174
172
  const controller = new AbortController();
175
173
  activeLoad = controller;
174
+ return controller;
175
+ }
176
+ async function loadMigratedDocument(input, loadOptions, importDetails, startedLoad) {
177
+ ensureUsable();
178
+ protectUnsavedChanges(loadOptions.discardUnsavedChanges);
179
+ const controller = startedLoad ?? startLoad();
176
180
  const documentId = describeDocumentId(input);
177
181
  events.emit("load:start", { documentId });
178
182
  try {
@@ -318,16 +322,24 @@ function createDocumentEngine(options) {
318
322
  ensureUsable();
319
323
  const source = requireStorage();
320
324
  protectUnsavedChanges(loadOptions?.discardUnsavedChanges);
325
+ const controller = startLoad();
321
326
  let stored;
322
327
  try {
323
328
  stored = await source.loadDocument(documentId);
329
+ if (controller.signal.aborted) throw new Error("aborted");
324
330
  } catch (error) {
331
+ if (activeLoad === controller) activeLoad = null;
332
+ if (controller.signal.aborted) {
333
+ const aborted = toLoadError(error, controller.signal);
334
+ events.emit("load:error", { error: aborted });
335
+ throw aborted;
336
+ }
325
337
  const reason = error instanceof Error ? error.message : String(error);
326
338
  const engineError = require_errors.isDocumentEngineError(error) ? error : new require_errors.DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
327
339
  events.emit("load:error", { error: engineError });
328
340
  throw engineError;
329
341
  }
330
- return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId });
342
+ return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId }, controller);
331
343
  }
332
344
  function save(saveOptions) {
333
345
  try {
@@ -335,6 +347,7 @@ function createDocumentEngine(options) {
335
347
  } catch (error) {
336
348
  return Promise.reject(error);
337
349
  }
350
+ history.flush();
338
351
  return saving.save(saveOptions);
339
352
  }
340
353
  function newDocument(newOptions = {}) {
@@ -495,7 +508,8 @@ function createDocumentEngine(options) {
495
508
  }
496
509
  function destroy() {
497
510
  if (destroyed) return;
498
- if (recovery && saving.state().isDirty) recovery.writeNow();
511
+ const { disposed } = canvas;
512
+ if (recovery && saving.state().isDirty && !disposed) recovery.writeNow();
499
513
  destroyed = true;
500
514
  activeLoad?.abort();
501
515
  history.destroy();
@@ -569,6 +583,7 @@ function createDocumentEngine(options) {
569
583
  getHistory: () => history.labels(),
570
584
  clearHistory() {
571
585
  ensureUsable();
586
+ history.flush();
572
587
  history.reset();
573
588
  },
574
589
  on: (name, handler) => events.on(name, handler),
@@ -167,12 +167,16 @@ function createDocumentEngine(options) {
167
167
  if (!storage || discardUnsavedChanges || !saving.state().isDirty) return;
168
168
  throw new DocumentEngineError("UNSAVED_CHANGES", "The current document has unsaved changes. Save first, or pass { discardUnsavedChanges: true } to replace it anyway.");
169
169
  }
170
- async function loadMigratedDocument(input, loadOptions, importDetails) {
171
- ensureUsable();
172
- protectUnsavedChanges(loadOptions.discardUnsavedChanges);
170
+ function startLoad() {
173
171
  activeLoad?.abort();
174
172
  const controller = new AbortController();
175
173
  activeLoad = controller;
174
+ return controller;
175
+ }
176
+ async function loadMigratedDocument(input, loadOptions, importDetails, startedLoad) {
177
+ ensureUsable();
178
+ protectUnsavedChanges(loadOptions.discardUnsavedChanges);
179
+ const controller = startedLoad ?? startLoad();
176
180
  const documentId = describeDocumentId(input);
177
181
  events.emit("load:start", { documentId });
178
182
  try {
@@ -318,16 +322,24 @@ function createDocumentEngine(options) {
318
322
  ensureUsable();
319
323
  const source = requireStorage();
320
324
  protectUnsavedChanges(loadOptions?.discardUnsavedChanges);
325
+ const controller = startLoad();
321
326
  let stored;
322
327
  try {
323
328
  stored = await source.loadDocument(documentId);
329
+ if (controller.signal.aborted) throw new Error("aborted");
324
330
  } catch (error) {
331
+ if (activeLoad === controller) activeLoad = null;
332
+ if (controller.signal.aborted) {
333
+ const aborted = toLoadError(error, controller.signal);
334
+ events.emit("load:error", { error: aborted });
335
+ throw aborted;
336
+ }
325
337
  const reason = error instanceof Error ? error.message : String(error);
326
338
  const engineError = isDocumentEngineError(error) ? error : new DocumentEngineError("LOAD_FAILED", `Storage could not load "${documentId}": ${reason}`, { cause: error });
327
339
  events.emit("load:error", { error: engineError });
328
340
  throw engineError;
329
341
  }
330
- return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId });
342
+ return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId }, controller);
331
343
  }
332
344
  function save(saveOptions) {
333
345
  try {
@@ -335,6 +347,7 @@ function createDocumentEngine(options) {
335
347
  } catch (error) {
336
348
  return Promise.reject(error);
337
349
  }
350
+ history.flush();
338
351
  return saving.save(saveOptions);
339
352
  }
340
353
  function newDocument(newOptions = {}) {
@@ -495,7 +508,8 @@ function createDocumentEngine(options) {
495
508
  }
496
509
  function destroy() {
497
510
  if (destroyed) return;
498
- if (recovery && saving.state().isDirty) recovery.writeNow();
511
+ const { disposed } = canvas;
512
+ if (recovery && saving.state().isDirty && !disposed) recovery.writeNow();
499
513
  destroyed = true;
500
514
  activeLoad?.abort();
501
515
  history.destroy();
@@ -569,6 +583,7 @@ function createDocumentEngine(options) {
569
583
  getHistory: () => history.labels(),
570
584
  clearHistory() {
571
585
  ensureUsable();
586
+ history.flush();
572
587
  history.reset();
573
588
  },
574
589
  on: (name, handler) => events.on(name, handler),
@@ -178,6 +178,7 @@ function createHistory(options) {
178
178
  redo: stack.redoLabels()
179
179
  }),
180
180
  withoutRecording,
181
+ flush: commitPendingChanges,
181
182
  reset() {
182
183
  pendingChanges = [];
183
184
  stack.clear();
@@ -178,6 +178,7 @@ function createHistory(options) {
178
178
  redo: stack.redoLabels()
179
179
  }),
180
180
  withoutRecording,
181
+ flush: commitPendingChanges,
181
182
  reset() {
182
183
  pendingChanges = [];
183
184
  stack.clear();
package/package.json CHANGED
@@ -1,27 +1,40 @@
1
1
  {
2
2
  "name": "fabricjs-document-engine",
3
- "version": "1.0.0",
4
- "description": "Turn an existing Fabric.js canvas into a dependable editable document: stable object ids, versioned save and load, undo and redo, safe autosave with conflict protection, custom objects and clear errors.",
3
+ "version": "1.0.2",
4
+ "description": "Fabric.js save and load, undo and redo, and autosave for an existing canvas: stable object ids, custom objects, export, recovery and save-conflict protection.",
5
5
  "keywords": [
6
- "fabric",
7
6
  "fabricjs",
8
7
  "fabric.js",
8
+ "fabric",
9
+ "fabric js",
9
10
  "canvas",
10
11
  "canvas editor",
11
- "document",
12
- "document engine",
13
- "save",
14
- "load",
15
- "serialization",
12
+ "fabricjs editor",
13
+ "design editor",
14
+ "image editor",
15
+ "undo redo",
16
16
  "undo",
17
17
  "redo",
18
+ "history",
19
+ "save canvas json",
20
+ "load from json",
21
+ "tojson",
22
+ "loadfromjson",
23
+ "serialization",
18
24
  "autosave",
19
- "editor",
20
- "typescript",
21
- "storage",
25
+ "custom objects",
26
+ "custom properties",
27
+ "object id",
28
+ "export svg",
29
+ "export image",
22
30
  "recovery",
31
+ "indexeddb",
23
32
  "react",
24
- "react hooks"
33
+ "react hooks",
34
+ "nextjs",
35
+ "vue",
36
+ "svelte",
37
+ "typescript"
25
38
  ],
26
39
  "license": "MIT",
27
40
  "author": "Sohail Khan (https://me.jscrate.dev)",
@@ -31,6 +44,7 @@
31
44
  "dist",
32
45
  "schema",
33
46
  "README.md",
47
+ "README.zh-CN.md",
34
48
  "LICENSE"
35
49
  ],
36
50
  "main": "./dist/index.cjs",
@@ -108,7 +122,7 @@
108
122
  "type": "git",
109
123
  "url": "git+https://github.com/re-sohail/fabricjs-document-engine.git"
110
124
  },
111
- "homepage": "https://github.com/re-sohail/fabricjs-document-engine#readme",
125
+ "homepage": "https://fabricjs-document-engine.jscrate.dev",
112
126
  "bugs": {
113
127
  "url": "https://github.com/re-sohail/fabricjs-document-engine/issues"
114
128
  },