fabricjs-document-engine 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +45 -91
  2. package/dist/assets/asset-pipeline.cjs +3 -1
  3. package/dist/assets/asset-pipeline.js +3 -1
  4. package/dist/engine/create-document-engine.cjs +16 -6
  5. package/dist/engine/create-document-engine.d.cts +7 -1
  6. package/dist/engine/create-document-engine.d.ts +7 -1
  7. package/dist/engine/create-document-engine.js +17 -7
  8. package/dist/engine/errors.d.cts +1 -1
  9. package/dist/engine/errors.d.ts +1 -1
  10. package/dist/export/render-export.cjs +3 -15
  11. package/dist/export/render-export.js +4 -15
  12. package/dist/history/apply-state.cjs +3 -1
  13. package/dist/history/apply-state.js +3 -1
  14. package/dist/history/create-history.cjs +4 -1
  15. package/dist/history/create-history.d.cts +1 -0
  16. package/dist/history/create-history.d.ts +1 -0
  17. package/dist/history/create-history.js +4 -1
  18. package/dist/history/history-stack.cjs +44 -8
  19. package/dist/history/history-stack.js +44 -9
  20. package/dist/index.cjs +4 -0
  21. package/dist/index.d.cts +3 -2
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +2 -1
  24. package/dist/recovery/recovery-controller.cjs +30 -3
  25. package/dist/recovery/recovery-controller.js +30 -3
  26. package/dist/security/content-limits.cjs +86 -0
  27. package/dist/security/content-limits.d.cts +11 -0
  28. package/dist/security/content-limits.d.ts +11 -0
  29. package/dist/security/content-limits.js +84 -0
  30. package/dist/storage/verify-storage-adapter.cjs +125 -0
  31. package/dist/storage/verify-storage-adapter.d.cts +13 -0
  32. package/dist/storage/verify-storage-adapter.d.ts +13 -0
  33. package/dist/storage/verify-storage-adapter.js +125 -0
  34. package/dist/storage.cjs +2 -0
  35. package/dist/storage.d.cts +2 -1
  36. package/dist/storage.d.ts +2 -1
  37. package/dist/storage.js +2 -1
  38. package/package.json +5 -2
  39. package/schema/document-v1.schema.json +69 -0
package/README.md CHANGED
@@ -16,6 +16,7 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
16
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
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
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).
19
20
  - **Fabric 6 and 7.** Every release is tested against both.
20
21
 
21
22
  ## Install
@@ -155,6 +156,7 @@ engine.on('save:status', ({ status, isDirty, revision, lastSavedAt, error }) =>
155
156
  - **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`.
156
157
  - **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 })`.
157
158
  - **Retries.** Temporary failures are retried with exponential backoff and jitter. Each retry emits `save:retry` with `{ attempt, delay, error }`.
159
+ - **Unsaved work is never replaced silently.** With a storage adapter, `load`, `loadDocument`, `importFabricJson` and `newDocument` refuse with `UNSAVED_CHANGES` while there are unsaved changes. Save first, or pass `{ discardUnsavedChanges: true }` when the user chose to throw the changes away.
158
160
 
159
161
  ### Warn before leaving
160
162
 
@@ -401,97 +403,49 @@ interface FabricDocument {
401
403
 
402
404
  Keep project data such as titles, owners and tags in `metadata` with `engine.updateMetadata()`, rather than on Fabric objects.
403
405
 
404
- ## API
405
-
406
- | Member | What it does |
407
- | --- | --- |
408
- | `createDocumentEngine({ canvas, storage?, customObjects?, document? })` | Connects the engine to your canvas. |
409
- | `engine.toDocument()` | Serializes the canvas into the versioned document format. |
410
- | `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
411
- | `engine.load(id)` / `engine.save({ overwrite? })` | Reads from or writes to your storage adapter. |
412
- | `engine.importFabricJson(json, { id?, metadata? })` | Opens plain Fabric JSON, as text or an object. |
413
- | `engine.createVersion(name?)` / `engine.listVersions()` | Keeps a named version, or lists versions newest first. |
414
- | `engine.restoreVersion(id)` / `engine.deleteVersion(id)` | Restores a version as a new unsaved revision, or deletes it. |
415
- | `engine.isDirty()` | Tells you whether there are unsaved changes. |
416
- | `engine.getSaveState()` | Returns `{ status, isDirty, isSaving, revision, lastSavedAt, error }`. |
417
- | `bindUnsavedChangesWarning(engine)` | Asks the browser to confirm before closing a page with unsaved changes. Returns an unbind function. |
418
- | `engine.newDocument({ id?, metadata? })` | Clears the canvas and starts a fresh document. |
419
- | `engine.getDocumentInfo()` | Returns the current document's id, dates and metadata. |
420
- | `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
421
- | `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
422
- | `engine.registerObject({ fabricClass, properties })` | Registers a custom class after the engine is created. |
423
- | `engine.getAssetManifest()` | Lists the images and fonts used on the canvas. |
424
- | `engine.checkAssets()` | Resolves to `{ manifest, missingImages, unavailableFonts, warnings }` for the current canvas. |
425
- | `engine.replaceImage(oldUrl, newUrl)` | Replaces every image with that URL as one undo step. Resolves to the number of images replaced. |
426
- | `engine.export(options)` | Exports PNG, JPEG, WebP, SVG or JSON. See [Export](#export). |
427
- | `engine.preflightExport(options)` | Resolves to `{ ok, problems, warnings }` without exporting. |
428
- | `downloadExport(result, fileName?)` | Starts a browser download of an export result. |
429
- | `engine.getRecoverableDocuments()` | Lists recovery copies, newest first. |
430
- | `engine.restoreRecovery(id?)` / `engine.discardRecovery(id?)` | Loads or deletes a recovery copy. The default is the current document. |
431
- | `engine.getRecovery(id?)` / `engine.flushRecovery()` | Reads a copy, or writes one now. |
432
- | `engine.getInterruptedLoad()` | Returns the load that was running when the tab last crashed, if any. |
433
- | `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
434
- | `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
435
- | `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
436
- | `engine.canUndo()` / `engine.canRedo()` | Tells you whether a step is available. |
437
- | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
438
- | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
439
- | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
440
- | `engine.on(event, handler)` | Listens to `load:start`, `document:change`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `export:success`, `export:error`, `version:created`, `version:restored`, `version:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
441
- | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
442
- | `createDocumentStateStore(engine)` | Framework-free `{ getSnapshot, subscribe, destroy }` state for toolbars. |
443
- | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
444
-
445
- ## Errors
446
-
447
- Every failure is a `DocumentEngineError` with a `code` you can switch on:
448
-
449
- | Code | Meaning |
450
- | --- | --- |
451
- | `INVALID_DOCUMENT` | The document shape is wrong. `error.issues` lists each path, such as `objects[3].objects[1].type`. |
452
- | `UNSUPPORTED_SCHEMA` | The document was written by a newer version of this package. |
453
- | `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
454
- | `LOAD_FAILED` | Fabric or your storage could not load the document, for example because an image is missing. `error.cause` holds the original error. |
455
- | `LOAD_ABORTED` | A newer load started before this one finished. |
456
- | `MISSING_ASSETS` | Images could not be loaded and had no replacement. `error.missingAssets` lists each `{ url, objectIds }`. |
457
- | `MISSING_FONTS` | Fonts are not available and `requireFonts` is on. `error.missingFonts` lists them. |
458
- | `ASSET_UPLOAD_FAILED` | Your `upload` handler failed while saving. |
459
- | `SAVE_FAILED` | Your storage adapter rejected the save after all retries. `error.retryable` tells you whether trying again could help. |
460
- | `SAVE_CONFLICT` | Another tab or device saved this document first. |
461
- | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
462
- | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
463
- | `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. |
464
- | `MIGRATION_FAILED` | An older document could not be upgraded. `error.migrationFrom` is the schema version it started from. |
465
- | `VERSIONS_UNSUPPORTED` | The storage adapter has no version methods. |
466
- | `VERSION_NOT_FOUND` | There is no version with that id. |
467
- | `VERSION_FAILED` | An automatic version could not be kept. It is delivered as a `version:error` event. |
468
- | `EXPORT_BLOCKED` | The preflight found problems. `error.problems` lists each one with the objects involved. |
469
- | `INVALID_EXPORT_OPTIONS` | The format, scale, quality, area or padding is not valid, or the area is empty. |
470
- | `EXPORT_ABORTED` | The export was cancelled with its `signal`. |
471
- | `EXPORT_FAILED` | Fabric could not render the export. `error.cause` holds the original error. |
472
- | `RECOVERY_MISSING` | A recovery method was called without `recovery: { store }`. |
473
- | `RECOVERY_NOT_FOUND` | There is no recovery copy for that document. |
474
- | `RECOVERY_FAILED` | Writing a recovery copy failed, for example because storage is full. It is delivered as a `recovery:error` event and never interrupts editing. |
475
- | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
476
- | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
477
- | `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
478
-
479
- A failed load never clears or half-fills your canvas.
480
-
481
- ## Roadmap
482
-
483
- | Stage | Focus | Released in |
484
- | --- | --- | --- |
485
- | 1 | Document foundation: ids, save and load, validation, custom objects | 0.0.0 |
486
- | 2 | Undo and redo with transactions | 0.1.0 |
487
- | 3 | Safe saving: dirty state, autosave, stale-response protection | 0.2.0 |
488
- | 4 | Assets and fonts | 0.3.0 |
489
- | 5 | Recovery after a refresh or crash | 0.4.0 |
490
- | 6 | PNG, JPEG, SVG and JSON export with preflight checks | 0.5.0 |
491
- | 7 | Named versions and schema migrations | 0.6.0 |
492
- | 8 | React adapter and examples | 0.7.0 |
493
- | 9 | Hardening and benchmarks | |
494
- | 10 | Stable API | 1.0.0 |
406
+ The format is described by a JSON Schema that ships with the package:
407
+
408
+ ```ts
409
+ import schema from 'fabricjs-document-engine/schema/document-v1.json';
410
+ ```
411
+
412
+ ## API reference
413
+
414
+ 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.
415
+
416
+ ## Stability
417
+
418
+ Version 1.0 freezes the document format and the adapter contracts:
419
+
420
+ - Documents are validated by the published JSON Schema at `fabricjs-document-engine/schema/document-v1.json`. Every 1.x release reads every document written by earlier releases, as well as plain Fabric JSON from Fabric 5, 6 and 7.
421
+ - Public API names, options, events and error codes do not change within 1.x. New ones may be added.
422
+ - 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
+
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).
425
+
426
+ ## Imported content and limits
427
+
428
+ Documents often come from users, so the engine treats them as untrusted:
429
+
430
+ - Keys named `__proto__`, `constructor` or `prototype` are removed before Fabric sees them. Fabric copies every key onto the object it creates, so these keys could otherwise change an object's prototype.
431
+ - Image addresses are checked after `assets.resolveUrl`, before anything is fetched. `http:`, `https:`, `blob:`, relative addresses and `data:image/...` are allowed. `javascript:`, `file:` and non-image `data:` addresses are refused with `UNSAFE_DOCUMENT`.
432
+ - A document with more than 50,000 objects, or nested more than 100 levels deep, is refused before loading, so a hostile file cannot freeze the tab.
433
+
434
+ ```ts
435
+ createDocumentEngine({
436
+ canvas,
437
+ limits: {
438
+ maxObjects: 10_000,
439
+ maxDepth: 40,
440
+ isAllowedUrl: (url) => url.startsWith('https://cdn.example.com/'),
441
+ },
442
+ history: { limit: 100, maxBytes: 32 * 1024 * 1024 },
443
+ });
444
+ ```
445
+
446
+ `history.maxBytes` caps the memory undo history uses. The default is 64 MB, and the oldest steps are dropped first. SVG export escapes text, so text such as `<script>` inside a text box stays text.
447
+
448
+ Accessibility guidance for your toolbar, status text and dialogs is in [docs/accessibility.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/accessibility.md).
495
449
 
496
450
  ## Troubleshooting
497
451
 
@@ -2,6 +2,7 @@ const require_asset_references = require("./asset-references.cjs");
2
2
  const require_asset_manifest = require("./asset-manifest.cjs");
3
3
  const require_errors = require("../engine/errors.cjs");
4
4
  const require_font_check = require("./font-check.cjs");
5
+ const require_content_limits = require("../security/content-limits.cjs");
5
6
  const require_image_check = require("./image-check.cjs");
6
7
  //#region src/assets/asset-pipeline.ts
7
8
  function cloneDocument(document) {
@@ -105,10 +106,11 @@ async function replaceMissingImages(document, missingImages, options, signal) {
105
106
  warnings
106
107
  };
107
108
  }
108
- async function prepareAssetsForLoad(input, options, signal) {
109
+ async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
109
110
  const document = cloneDocument(input);
110
111
  const { resolveUrl } = options;
111
112
  if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
113
+ require_content_limits.refuseUnsafeImageUrls(document.objects, isAllowedUrl);
112
114
  const report = await inspectAssets(document, options, signal);
113
115
  if (options.requireFonts && report.unavailableFonts.length > 0) {
114
116
  const families = report.unavailableFonts.map((font) => font.family).join(", ");
@@ -2,6 +2,7 @@ import { findImageReferences } from "./asset-references.js";
2
2
  import { buildAssetManifest, isEmbeddedUrl } from "./asset-manifest.js";
3
3
  import { DocumentEngineError, isDocumentEngineError } from "../engine/errors.js";
4
4
  import { findUnavailableFonts } from "./font-check.js";
5
+ import { refuseUnsafeImageUrls } from "../security/content-limits.js";
5
6
  import { findMissingImages, isCrossOriginUrl, isPortableUrl } from "./image-check.js";
6
7
  //#region src/assets/asset-pipeline.ts
7
8
  function cloneDocument(document) {
@@ -105,10 +106,11 @@ async function replaceMissingImages(document, missingImages, options, signal) {
105
106
  warnings
106
107
  };
107
108
  }
108
- async function prepareAssetsForLoad(input, options, signal) {
109
+ async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
109
110
  const document = cloneDocument(input);
110
111
  const { resolveUrl } = options;
111
112
  if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
113
+ refuseUnsafeImageUrls(document.objects, isAllowedUrl);
112
114
  const report = await inspectAssets(document, options, signal);
113
115
  if (options.requireFonts && report.unavailableFonts.length > 0) {
114
116
  const families = report.unavailableFonts.map((font) => font.family).join(", ");
@@ -1,5 +1,6 @@
1
1
  const require_asset_manifest = require("../assets/asset-manifest.cjs");
2
2
  const require_errors = require("./errors.cjs");
3
+ const require_content_limits = require("../security/content-limits.cjs");
3
4
  const require_image_check = require("../assets/image-check.cjs");
4
5
  const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
5
6
  const require_export_area = require("../export/export-area.cjs");
@@ -72,6 +73,7 @@ function createDocumentEngine(options) {
72
73
  const history = require_create_history.createHistory({
73
74
  canvas,
74
75
  limit: options.history?.limit,
76
+ maxBytes: options.history?.maxBytes,
75
77
  serializeObjects: () => {
76
78
  rebuildIndex();
77
79
  return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
@@ -161,8 +163,13 @@ function createDocumentEngine(options) {
161
163
  function loadDocument(input, loadOptions = {}) {
162
164
  return loadMigratedDocument(input, loadOptions, {});
163
165
  }
166
+ function protectUnsavedChanges(discardUnsavedChanges) {
167
+ if (!storage || discardUnsavedChanges || !saving.state().isDirty) return;
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
+ }
164
170
  async function loadMigratedDocument(input, loadOptions, importDetails) {
165
171
  ensureUsable();
172
+ protectUnsavedChanges(loadOptions.discardUnsavedChanges);
166
173
  activeLoad?.abort();
167
174
  const controller = new AbortController();
168
175
  activeLoad = controller;
@@ -171,13 +178,13 @@ function createDocumentEngine(options) {
171
178
  try {
172
179
  await recovery?.markLoadStarted(documentId);
173
180
  if (controller.signal.aborted) throw new Error("aborted");
174
- const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(input, {
181
+ const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(require_content_limits.secureDocument(input, options.limits), {
175
182
  canvasWidth: canvas.getWidth(),
176
183
  canvasHeight: canvas.getHeight(),
177
184
  ...importDetails
178
185
  });
179
186
  const checked = checkDocument(migrated);
180
- const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
187
+ const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
181
188
  if (controller.signal.aborted) throw new Error("aborted");
182
189
  await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
183
190
  version: document.fabricVersion,
@@ -295,7 +302,7 @@ function createDocumentEngine(options) {
295
302
  id: current.id,
296
303
  createdAt: current.createdAt,
297
304
  revision: baseRevision
298
- });
305
+ }, { discardUnsavedChanges: true });
299
306
  noteContentChange();
300
307
  events.emit("version:restored", {
301
308
  version: require_document_version.summarize(version),
@@ -310,6 +317,7 @@ function createDocumentEngine(options) {
310
317
  async function load(documentId, loadOptions) {
311
318
  ensureUsable();
312
319
  const source = requireStorage();
320
+ protectUnsavedChanges(loadOptions?.discardUnsavedChanges);
313
321
  let stored;
314
322
  try {
315
323
  stored = await source.loadDocument(documentId);
@@ -329,8 +337,9 @@ function createDocumentEngine(options) {
329
337
  }
330
338
  return saving.save(saveOptions);
331
339
  }
332
- function newDocument(newOptions) {
340
+ function newDocument(newOptions = {}) {
333
341
  ensureUsable();
342
+ protectUnsavedChanges(newOptions.discardUnsavedChanges);
334
343
  activeLoad?.abort();
335
344
  history.withoutRecording(() => canvas.clear());
336
345
  objectsById.clear();
@@ -412,10 +421,10 @@ function createDocumentEngine(options) {
412
421
  const area = require_export_area.resolveExportArea(canvas, settings.area, settings.padding);
413
422
  return {
414
423
  area,
415
- content: format === "svg" ? require_render_export.renderSvg(canvas, area, settings.scale) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
424
+ content: format === "svg" ? Promise.resolve(new Blob([require_render_export.renderSvg(canvas, area, settings.scale)], { type: require_render_export.mimeTypes.svg })) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
416
425
  };
417
426
  });
418
- const blob = format === "svg" ? new Blob([rendered.content], { type: require_render_export.mimeTypes.svg }) : require_render_export.dataUrlToBlob(rendered.content);
427
+ const blob = await rendered.content;
419
428
  return {
420
429
  format,
421
430
  mimeType: blob.type || require_render_export.mimeTypes[format],
@@ -486,6 +495,7 @@ function createDocumentEngine(options) {
486
495
  }
487
496
  function destroy() {
488
497
  if (destroyed) return;
498
+ if (recovery && saving.state().isDirty) recovery.writeNow();
489
499
  destroyed = true;
490
500
  activeLoad?.abort();
491
501
  history.destroy();
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.cjs";
6
6
  import { NewDocumentOptions } from "../document/create-document.cjs";
7
7
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
8
8
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
9
+ import { ContentLimits } from "../security/content-limits.cjs";
9
10
  import { VersionOptions, VersionSummary } from "../versions/document-version.cjs";
10
11
  import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.cjs";
11
12
  import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
@@ -27,9 +28,14 @@ export interface DocumentEngineOptions {
27
28
  assets?: AssetOptions;
28
29
  recovery?: RecoveryOptions;
29
30
  versions?: VersionOptions;
31
+ limits?: ContentLimits;
30
32
  }
31
33
  export interface LoadOptions {
32
34
  restoreCanvasSize?: boolean;
35
+ discardUnsavedChanges?: boolean;
36
+ }
37
+ export interface NewDocumentRequest extends NewDocumentOptions {
38
+ discardUnsavedChanges?: boolean;
33
39
  }
34
40
  export interface ImportOptions extends LoadOptions {
35
41
  id?: string;
@@ -109,7 +115,7 @@ export interface DocumentEngine {
109
115
  readonly canvas: StaticCanvas;
110
116
  getDocumentInfo(): DocumentInfo;
111
117
  updateMetadata(changes: Record<string, unknown>): void;
112
- newDocument(options?: NewDocumentOptions): void;
118
+ newDocument(options?: NewDocumentRequest): void;
113
119
  toDocument(): FabricDocument;
114
120
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
115
121
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.js";
6
6
  import { NewDocumentOptions } from "../document/create-document.js";
7
7
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
8
8
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
9
+ import { ContentLimits } from "../security/content-limits.js";
9
10
  import { VersionOptions, VersionSummary } from "../versions/document-version.js";
10
11
  import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.js";
11
12
  import { AutosaveOptions } from "../save/autosave-scheduler.js";
@@ -27,9 +28,14 @@ export interface DocumentEngineOptions {
27
28
  assets?: AssetOptions;
28
29
  recovery?: RecoveryOptions;
29
30
  versions?: VersionOptions;
31
+ limits?: ContentLimits;
30
32
  }
31
33
  export interface LoadOptions {
32
34
  restoreCanvasSize?: boolean;
35
+ discardUnsavedChanges?: boolean;
36
+ }
37
+ export interface NewDocumentRequest extends NewDocumentOptions {
38
+ discardUnsavedChanges?: boolean;
33
39
  }
34
40
  export interface ImportOptions extends LoadOptions {
35
41
  id?: string;
@@ -109,7 +115,7 @@ export interface DocumentEngine {
109
115
  readonly canvas: StaticCanvas;
110
116
  getDocumentInfo(): DocumentInfo;
111
117
  updateMetadata(changes: Record<string, unknown>): void;
112
- newDocument(options?: NewDocumentOptions): void;
118
+ newDocument(options?: NewDocumentRequest): void;
113
119
  toDocument(): FabricDocument;
114
120
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
115
121
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
@@ -1,5 +1,6 @@
1
1
  import { buildAssetManifest } from "../assets/asset-manifest.js";
2
2
  import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
3
+ import { secureDocument } from "../security/content-limits.js";
3
4
  import { isSameUrl } from "../assets/image-check.js";
4
5
  import { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave } from "../assets/asset-pipeline.js";
5
6
  import { resolveExportArea } from "../export/export-area.js";
@@ -7,7 +8,7 @@ import { normalizeExportOptions } from "../export/export-options.js";
7
8
  import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
8
9
  import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
9
10
  import { preflightExport } from "../export/preflight-export.js";
10
- import { dataUrlToBlob, mimeTypes, renderRaster, renderSvg, withExportView } from "../export/render-export.js";
11
+ import { mimeTypes, renderRaster, renderSvg, withExportView } from "../export/render-export.js";
11
12
  import "../document/document-format.js";
12
13
  import { createId } from "../document/ids.js";
13
14
  import { createDocumentInfo } from "../document/create-document.js";
@@ -72,6 +73,7 @@ function createDocumentEngine(options) {
72
73
  const history = createHistory({
73
74
  canvas,
74
75
  limit: options.history?.limit,
76
+ maxBytes: options.history?.maxBytes,
75
77
  serializeObjects: () => {
76
78
  rebuildIndex();
77
79
  return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
@@ -161,8 +163,13 @@ function createDocumentEngine(options) {
161
163
  function loadDocument(input, loadOptions = {}) {
162
164
  return loadMigratedDocument(input, loadOptions, {});
163
165
  }
166
+ function protectUnsavedChanges(discardUnsavedChanges) {
167
+ if (!storage || discardUnsavedChanges || !saving.state().isDirty) return;
168
+ throw new DocumentEngineError("UNSAVED_CHANGES", "The current document has unsaved changes. Save first, or pass { discardUnsavedChanges: true } to replace it anyway.");
169
+ }
164
170
  async function loadMigratedDocument(input, loadOptions, importDetails) {
165
171
  ensureUsable();
172
+ protectUnsavedChanges(loadOptions.discardUnsavedChanges);
166
173
  activeLoad?.abort();
167
174
  const controller = new AbortController();
168
175
  activeLoad = controller;
@@ -171,13 +178,13 @@ function createDocumentEngine(options) {
171
178
  try {
172
179
  await recovery?.markLoadStarted(documentId);
173
180
  if (controller.signal.aborted) throw new Error("aborted");
174
- const { document: migrated, migratedFrom } = migrateDocument(input, {
181
+ const { document: migrated, migratedFrom } = migrateDocument(secureDocument(input, options.limits), {
175
182
  canvasWidth: canvas.getWidth(),
176
183
  canvasHeight: canvas.getHeight(),
177
184
  ...importDetails
178
185
  });
179
186
  const checked = checkDocument(migrated);
180
- const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
187
+ const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
181
188
  if (controller.signal.aborted) throw new Error("aborted");
182
189
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
183
190
  version: document.fabricVersion,
@@ -295,7 +302,7 @@ function createDocumentEngine(options) {
295
302
  id: current.id,
296
303
  createdAt: current.createdAt,
297
304
  revision: baseRevision
298
- });
305
+ }, { discardUnsavedChanges: true });
299
306
  noteContentChange();
300
307
  events.emit("version:restored", {
301
308
  version: summarize(version),
@@ -310,6 +317,7 @@ function createDocumentEngine(options) {
310
317
  async function load(documentId, loadOptions) {
311
318
  ensureUsable();
312
319
  const source = requireStorage();
320
+ protectUnsavedChanges(loadOptions?.discardUnsavedChanges);
313
321
  let stored;
314
322
  try {
315
323
  stored = await source.loadDocument(documentId);
@@ -329,8 +337,9 @@ function createDocumentEngine(options) {
329
337
  }
330
338
  return saving.save(saveOptions);
331
339
  }
332
- function newDocument(newOptions) {
340
+ function newDocument(newOptions = {}) {
333
341
  ensureUsable();
342
+ protectUnsavedChanges(newOptions.discardUnsavedChanges);
334
343
  activeLoad?.abort();
335
344
  history.withoutRecording(() => canvas.clear());
336
345
  objectsById.clear();
@@ -412,10 +421,10 @@ function createDocumentEngine(options) {
412
421
  const area = resolveExportArea(canvas, settings.area, settings.padding);
413
422
  return {
414
423
  area,
415
- content: format === "svg" ? renderSvg(canvas, area, settings.scale) : renderRaster(canvas, area, format, settings.scale, settings.quality)
424
+ content: format === "svg" ? Promise.resolve(new Blob([renderSvg(canvas, area, settings.scale)], { type: mimeTypes.svg })) : renderRaster(canvas, area, format, settings.scale, settings.quality)
416
425
  };
417
426
  });
418
- const blob = format === "svg" ? new Blob([rendered.content], { type: mimeTypes.svg }) : dataUrlToBlob(rendered.content);
427
+ const blob = await rendered.content;
419
428
  return {
420
429
  format,
421
430
  mimeType: blob.type || mimeTypes[format],
@@ -486,6 +495,7 @@ function createDocumentEngine(options) {
486
495
  }
487
496
  function destroy() {
488
497
  if (destroyed) return;
498
+ if (recovery && saving.state().isDirty) recovery.writeNow();
489
499
  destroyed = true;
490
500
  activeLoad?.abort();
491
501
  history.destroy();
@@ -1,7 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
2
2
  import { ExportProblem } from "../export/preflight-export.cjs";
3
3
  //#region src/engine/errors.d.ts
4
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
5
5
  export interface DocumentIssue {
6
6
  code: DocumentErrorCode;
7
7
  path: string;
@@ -1,7 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
2
2
  import { ExportProblem } from "../export/preflight-export.js";
3
3
  //#region src/engine/errors.d.ts
4
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
5
5
  export interface DocumentIssue {
6
6
  code: DocumentErrorCode;
7
7
  path: string;
@@ -33,12 +33,9 @@ function withExportView(canvas, background, format, work) {
33
33
  }
34
34
  }
35
35
  function renderRaster(canvas, area, format, scale, quality) {
36
- return canvas.toDataURL({
37
- ...area,
38
- format,
39
- quality,
40
- multiplier: scale,
41
- enableRetinaScaling: false
36
+ const rendered = canvas.toCanvasElement(scale, area);
37
+ return new Promise((resolve, reject) => {
38
+ rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
42
39
  });
43
40
  }
44
41
  function renderSvg(canvas, area, scale) {
@@ -53,16 +50,7 @@ function renderSvg(canvas, area, scale) {
53
50
  height: String(area.height * scale)
54
51
  });
55
52
  }
56
- function dataUrlToBlob(dataUrl) {
57
- const [header, payload = ""] = dataUrl.split(",");
58
- const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
59
- const binary = atob(payload);
60
- const bytes = new Uint8Array(binary.length);
61
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
62
- return new Blob([bytes], { type: mimeType });
63
- }
64
53
  //#endregion
65
- exports.dataUrlToBlob = dataUrlToBlob;
66
54
  exports.mimeTypes = mimeTypes;
67
55
  exports.renderRaster = renderRaster;
68
56
  exports.renderSvg = renderSvg;
@@ -33,12 +33,9 @@ function withExportView(canvas, background, format, work) {
33
33
  }
34
34
  }
35
35
  function renderRaster(canvas, area, format, scale, quality) {
36
- return canvas.toDataURL({
37
- ...area,
38
- format,
39
- quality,
40
- multiplier: scale,
41
- enableRetinaScaling: false
36
+ const rendered = canvas.toCanvasElement(scale, area);
37
+ return new Promise((resolve, reject) => {
38
+ rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
42
39
  });
43
40
  }
44
41
  function renderSvg(canvas, area, scale) {
@@ -53,13 +50,5 @@ function renderSvg(canvas, area, scale) {
53
50
  height: String(area.height * scale)
54
51
  });
55
52
  }
56
- function dataUrlToBlob(dataUrl) {
57
- const [header, payload = ""] = dataUrl.split(",");
58
- const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
59
- const binary = atob(payload);
60
- const bytes = new Uint8Array(binary.length);
61
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
62
- return new Blob([bytes], { type: mimeType });
63
- }
64
53
  //#endregion
65
- export { dataUrlToBlob, mimeTypes, renderRaster, renderSvg, withExportView };
54
+ export { mimeTypes, renderRaster, renderSvg, withExportView };
@@ -34,10 +34,12 @@ async function applyStateChange(canvas, change) {
34
34
  try {
35
35
  const outgoing = current.filter((object) => !kept.has(object));
36
36
  if (outgoing.length > 0) canvas.remove(...outgoing);
37
+ let onCanvas = canvas.getObjects();
37
38
  arranged.forEach((object, index) => {
38
- if (canvas.getObjects()[index] === object) return;
39
+ if (onCanvas[index] === object) return;
39
40
  if (object.canvas === canvas) canvas.moveObjectTo(object, index);
40
41
  else canvas.insertAt(index, object);
42
+ onCanvas = canvas.getObjects();
41
43
  });
42
44
  } finally {
43
45
  canvas.renderOnAddRemove = renderOnAddRemove;
@@ -34,10 +34,12 @@ async function applyStateChange(canvas, change) {
34
34
  try {
35
35
  const outgoing = current.filter((object) => !kept.has(object));
36
36
  if (outgoing.length > 0) canvas.remove(...outgoing);
37
+ let onCanvas = canvas.getObjects();
37
38
  arranged.forEach((object, index) => {
38
- if (canvas.getObjects()[index] === object) return;
39
+ if (onCanvas[index] === object) return;
39
40
  if (object.canvas === canvas) canvas.moveObjectTo(object, index);
40
41
  else canvas.insertAt(index, object);
42
+ onCanvas = canvas.getObjects();
41
43
  });
42
44
  } finally {
43
45
  canvas.renderOnAddRemove = renderOnAddRemove;
@@ -25,7 +25,10 @@ function describePendingChanges(changes) {
25
25
  }
26
26
  function createHistory(options) {
27
27
  const { canvas, serializeObjects, onChange, onContentChange } = options;
28
- const stack = require_history_stack.createHistoryStack(Math.max(1, options.limit ?? 100));
28
+ const stack = require_history_stack.createHistoryStack({
29
+ steps: Math.max(1, options.limit ?? 100),
30
+ bytes: Math.max(0, options.maxBytes ?? 67108864)
31
+ });
29
32
  let snapshot = require_snapshot.createSnapshot(serializeObjects());
30
33
  let pendingChanges = [];
31
34
  let flushScheduled = false;
@@ -2,6 +2,7 @@ import { StaticCanvas } from "fabric";
2
2
  //#region src/history/create-history.d.ts
3
3
  export interface HistoryOptions {
4
4
  limit?: number;
5
+ maxBytes?: number;
5
6
  }
6
7
  export interface HistoryState {
7
8
  canUndo: boolean;
@@ -2,6 +2,7 @@ import { StaticCanvas } from "fabric";
2
2
  //#region src/history/create-history.d.ts
3
3
  export interface HistoryOptions {
4
4
  limit?: number;
5
+ maxBytes?: number;
5
6
  }
6
7
  export interface HistoryState {
7
8
  canUndo: boolean;
@@ -25,7 +25,10 @@ function describePendingChanges(changes) {
25
25
  }
26
26
  function createHistory(options) {
27
27
  const { canvas, serializeObjects, onChange, onContentChange } = options;
28
- const stack = createHistoryStack(Math.max(1, options.limit ?? 100));
28
+ const stack = createHistoryStack({
29
+ steps: Math.max(1, options.limit ?? 100),
30
+ bytes: Math.max(0, options.maxBytes ?? 67108864)
31
+ });
29
32
  let snapshot = createSnapshot(serializeObjects());
30
33
  let pendingChanges = [];
31
34
  let flushScheduled = false;