fabricjs-document-engine 0.6.0 → 0.8.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 (54) hide show
  1. package/README.md +74 -3
  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 +8 -4
  5. package/dist/engine/create-document-engine.d.cts +5 -0
  6. package/dist/engine/create-document-engine.d.ts +5 -0
  7. package/dist/engine/create-document-engine.js +9 -5
  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 +6 -0
  21. package/dist/index.d.cts +3 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.js +3 -1
  24. package/dist/react/engine-context.cjs +12 -0
  25. package/dist/react/engine-context.d.cts +10 -0
  26. package/dist/react/engine-context.d.ts +10 -0
  27. package/dist/react/engine-context.js +11 -0
  28. package/dist/react/use-document-engine.cjs +23 -0
  29. package/dist/react/use-document-engine.d.cts +6 -0
  30. package/dist/react/use-document-engine.d.ts +6 -0
  31. package/dist/react/use-document-engine.js +23 -0
  32. package/dist/react/use-document-event.cjs +12 -0
  33. package/dist/react/use-document-event.d.cts +4 -0
  34. package/dist/react/use-document-event.d.ts +4 -0
  35. package/dist/react/use-document-event.js +12 -0
  36. package/dist/react/use-document-state.cjs +11 -0
  37. package/dist/react/use-document-state.d.cts +5 -0
  38. package/dist/react/use-document-state.d.ts +5 -0
  39. package/dist/react/use-document-state.js +11 -0
  40. package/dist/react.cjs +11 -0
  41. package/dist/react.d.cts +6 -0
  42. package/dist/react.d.ts +6 -0
  43. package/dist/react.js +6 -0
  44. package/dist/recovery/recovery-controller.cjs +29 -3
  45. package/dist/recovery/recovery-controller.js +29 -3
  46. package/dist/security/content-limits.cjs +86 -0
  47. package/dist/security/content-limits.d.cts +11 -0
  48. package/dist/security/content-limits.d.ts +11 -0
  49. package/dist/security/content-limits.js +84 -0
  50. package/dist/state/document-state-store.cjs +114 -0
  51. package/dist/state/document-state-store.d.cts +28 -0
  52. package/dist/state/document-state-store.d.ts +28 -0
  53. package/dist/state/document-state-store.js +114 -0
  54. package/package.json +27 -5
package/README.md CHANGED
@@ -15,6 +15,8 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
15
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
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
+ - **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).
18
20
  - **Fabric 6 and 7.** Every release is tested against both.
19
21
 
20
22
  ## Install
@@ -40,6 +42,42 @@ localStorage.setItem(document.id, JSON.stringify(document));
40
42
  await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
41
43
  ```
42
44
 
45
+ ## React
46
+
47
+ ```tsx
48
+ import { useDocumentEngine, useDocumentState, DocumentEngineProvider, useEngine } from 'fabricjs-document-engine/react';
49
+
50
+ function Editor({ canvas }: { canvas: Canvas | null }) {
51
+ const engine = useDocumentEngine(canvas, { storage, autosave: true });
52
+ return (
53
+ <DocumentEngineProvider engine={engine}>
54
+ <YourToolbar />
55
+ </DocumentEngineProvider>
56
+ );
57
+ }
58
+
59
+ function YourToolbar() {
60
+ const engine = useEngine();
61
+ const state = useDocumentState(engine);
62
+ if (!engine || !state) return null;
63
+ return (
64
+ <>
65
+ <button disabled={!state.canUndo} onClick={() => engine.undo()}>Undo {state.undoLabel}</button>
66
+ <button disabled={!state.isDirty} onClick={() => engine.save()}>Save</button>
67
+ <span>{state.saveStatus}</span>
68
+ </>
69
+ );
70
+ }
71
+ ```
72
+
73
+ - `useDocumentEngine(canvas, options)` creates the engine once your Fabric canvas exists and destroys it on unmount. It returns `null` until then. Options are read when the engine is created.
74
+ - `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
+ - `useDocumentEvent(engine, 'save:error', handler)` subscribes to any event with the latest handler.
76
+ - `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.
78
+
79
+ For other frameworks, `createDocumentStateStore(engine)` gives the same state as `{ getSnapshot, subscribe }`, which fits Svelte stores, Vue's `shallowRef` and similar tools.
80
+
43
81
  ## Saving and loading through storage
44
82
 
45
83
  The built-in adapters are the quickest way to start:
@@ -59,6 +97,8 @@ await engine.save();
59
97
 
60
98
  Both adapters also have `listDocuments()` and `deleteDocument(id)`. To build on another key-value store, use `createKeyValueStorage({ read, write, remove, keys }, prefix)`.
61
99
 
100
+ More adapters, including a REST API with revision checks and image uploads, are in [docs/storage-examples.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/storage-examples.md).
101
+
62
102
  ### Your own backend
63
103
 
64
104
  A storage adapter is two functions:
@@ -398,8 +438,10 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
398
438
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
399
439
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
400
440
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
401
- | `engine.on(event, handler)` | Listens to `load:start`, `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.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. |
402
442
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
443
+ | `secureDocument(value, limits?)` / `refuseUnsafeImageUrls(objects, isAllowed?)` / `isSafeImageUrl(url)` | The safety checks, exported for server-side validation. |
444
+ | `createDocumentStateStore(engine)` | Framework-free `{ getSnapshot, subscribe, destroy }` state for toolbars. |
403
445
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
404
446
 
405
447
  ## Errors
@@ -408,6 +450,7 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
408
450
 
409
451
  | Code | Meaning |
410
452
  | --- | --- |
453
+ | `UNSAFE_DOCUMENT` | The document broke a safety rule: an unsafe image address, too many objects, or nesting too deep. `error.issues` lists each one. |
411
454
  | `INVALID_DOCUMENT` | The document shape is wrong. `error.issues` lists each path, such as `objects[3].objects[1].type`. |
412
455
  | `UNSUPPORTED_SCHEMA` | The document was written by a newer version of this package. |
413
456
  | `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
@@ -449,10 +492,38 @@ A failed load never clears or half-fills your canvas.
449
492
  | 5 | Recovery after a refresh or crash | 0.4.0 |
450
493
  | 6 | PNG, JPEG, SVG and JSON export with preflight checks | 0.5.0 |
451
494
  | 7 | Named versions and schema migrations | 0.6.0 |
452
- | 8 | React adapter and examples | |
453
- | 9 | Hardening and benchmarks | |
495
+ | 8 | React adapter and examples | 0.7.0 |
496
+ | 9 | Hardening and benchmarks | 0.8.0 |
454
497
  | 10 | Stable API | 1.0.0 |
455
498
 
499
+ ## Imported content and limits
500
+
501
+ Documents often come from users, so the engine treats them as untrusted:
502
+
503
+ - 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.
504
+ - 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`.
505
+ - 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.
506
+
507
+ ```ts
508
+ createDocumentEngine({
509
+ canvas,
510
+ limits: {
511
+ maxObjects: 10_000,
512
+ maxDepth: 40,
513
+ isAllowedUrl: (url) => url.startsWith('https://cdn.example.com/'),
514
+ },
515
+ history: { limit: 100, maxBytes: 32 * 1024 * 1024 },
516
+ });
517
+ ```
518
+
519
+ `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.
520
+
521
+ 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).
522
+
523
+ ## Troubleshooting
524
+
525
+ Common problems and fixes are in [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md).
526
+
456
527
  ## License
457
528
 
458
529
  MIT
@@ -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;
@@ -171,13 +173,13 @@ function createDocumentEngine(options) {
171
173
  try {
172
174
  await recovery?.markLoadStarted(documentId);
173
175
  if (controller.signal.aborted) throw new Error("aborted");
174
- const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(input, {
176
+ const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(require_content_limits.secureDocument(input, options.limits), {
175
177
  canvasWidth: canvas.getWidth(),
176
178
  canvasHeight: canvas.getHeight(),
177
179
  ...importDetails
178
180
  });
179
181
  const checked = checkDocument(migrated);
180
- const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
182
+ const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
181
183
  if (controller.signal.aborted) throw new Error("aborted");
182
184
  await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
183
185
  version: document.fabricVersion,
@@ -201,6 +203,7 @@ function createDocumentEngine(options) {
201
203
  saving.startSession(document.revision ?? 0);
202
204
  canvas.requestRenderAll();
203
205
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
206
+ events.emit("document:change", { documentId: document.id });
204
207
  events.emit("load:success", {
205
208
  document,
206
209
  warnings,
@@ -338,6 +341,7 @@ function createDocumentEngine(options) {
338
341
  history.reset();
339
342
  recovery?.cancel();
340
343
  saving.startSession(0);
344
+ events.emit("document:change", { documentId: documentInfo.id });
341
345
  }
342
346
  function getObjectById(id) {
343
347
  ensureUsable();
@@ -410,10 +414,10 @@ function createDocumentEngine(options) {
410
414
  const area = require_export_area.resolveExportArea(canvas, settings.area, settings.padding);
411
415
  return {
412
416
  area,
413
- content: format === "svg" ? require_render_export.renderSvg(canvas, area, settings.scale) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
417
+ 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)
414
418
  };
415
419
  });
416
- const blob = format === "svg" ? new Blob([rendered.content], { type: require_render_export.mimeTypes.svg }) : require_render_export.dataUrlToBlob(rendered.content);
420
+ const blob = await rendered.content;
417
421
  return {
418
422
  format,
419
423
  mimeType: blob.type || require_render_export.mimeTypes[format],
@@ -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,6 +28,7 @@ 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;
@@ -48,6 +50,9 @@ export interface DocumentEngineEvents {
48
50
  "load:start": {
49
51
  documentId: string | undefined;
50
52
  };
53
+ "document:change": {
54
+ documentId: string;
55
+ };
51
56
  "load:success": {
52
57
  document: FabricDocument;
53
58
  warnings: AssetWarning[];
@@ -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,6 +28,7 @@ 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;
@@ -48,6 +50,9 @@ export interface DocumentEngineEvents {
48
50
  "load:start": {
49
51
  documentId: string | undefined;
50
52
  };
53
+ "document:change": {
54
+ documentId: string;
55
+ };
51
56
  "load:success": {
52
57
  document: FabricDocument;
53
58
  warnings: AssetWarning[];
@@ -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;
@@ -171,13 +173,13 @@ function createDocumentEngine(options) {
171
173
  try {
172
174
  await recovery?.markLoadStarted(documentId);
173
175
  if (controller.signal.aborted) throw new Error("aborted");
174
- const { document: migrated, migratedFrom } = migrateDocument(input, {
176
+ const { document: migrated, migratedFrom } = migrateDocument(secureDocument(input, options.limits), {
175
177
  canvasWidth: canvas.getWidth(),
176
178
  canvasHeight: canvas.getHeight(),
177
179
  ...importDetails
178
180
  });
179
181
  const checked = checkDocument(migrated);
180
- const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
182
+ const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
181
183
  if (controller.signal.aborted) throw new Error("aborted");
182
184
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
183
185
  version: document.fabricVersion,
@@ -201,6 +203,7 @@ function createDocumentEngine(options) {
201
203
  saving.startSession(document.revision ?? 0);
202
204
  canvas.requestRenderAll();
203
205
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
206
+ events.emit("document:change", { documentId: document.id });
204
207
  events.emit("load:success", {
205
208
  document,
206
209
  warnings,
@@ -338,6 +341,7 @@ function createDocumentEngine(options) {
338
341
  history.reset();
339
342
  recovery?.cancel();
340
343
  saving.startSession(0);
344
+ events.emit("document:change", { documentId: documentInfo.id });
341
345
  }
342
346
  function getObjectById(id) {
343
347
  ensureUsable();
@@ -410,10 +414,10 @@ function createDocumentEngine(options) {
410
414
  const area = resolveExportArea(canvas, settings.area, settings.padding);
411
415
  return {
412
416
  area,
413
- content: format === "svg" ? renderSvg(canvas, area, settings.scale) : renderRaster(canvas, area, format, settings.scale, settings.quality)
417
+ content: format === "svg" ? Promise.resolve(new Blob([renderSvg(canvas, area, settings.scale)], { type: mimeTypes.svg })) : renderRaster(canvas, area, format, settings.scale, settings.quality)
414
418
  };
415
419
  });
416
- const blob = format === "svg" ? new Blob([rendered.content], { type: mimeTypes.svg }) : dataUrlToBlob(rendered.content);
420
+ const blob = await rendered.content;
417
421
  return {
418
422
  format,
419
423
  mimeType: blob.type || mimeTypes[format],
@@ -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" | "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" | "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;
@@ -1,27 +1,63 @@
1
1
  //#region src/history/history-stack.ts
2
- function createHistoryStack(limit) {
2
+ function bytesOfChange(change) {
3
+ let bytes = (change.order?.length ?? 0) * 8;
4
+ for (const [id, json] of change.objects) bytes += (id.length + (json?.length ?? 0)) * 2;
5
+ return bytes;
6
+ }
7
+ function estimateStepBytes(step) {
8
+ return bytesOfChange(step.before) + bytesOfChange(step.after) + step.label.length * 2;
9
+ }
10
+ function createHistoryStack(limits) {
3
11
  const undoSteps = [];
4
12
  const redoSteps = [];
5
- function pushWithinLimit(steps, step) {
13
+ const sizes = /* @__PURE__ */ new WeakMap();
14
+ let totalBytes = 0;
15
+ function sizeOf(step) {
16
+ let size = sizes.get(step);
17
+ if (size === void 0) {
18
+ size = estimateStepBytes(step);
19
+ sizes.set(step, size);
20
+ }
21
+ return size;
22
+ }
23
+ function forget(step) {
24
+ if (step) totalBytes -= sizeOf(step);
25
+ }
26
+ function stayWithinLimits() {
27
+ while (undoSteps.length > limits.steps) forget(undoSteps.shift());
28
+ while (redoSteps.length > limits.steps) forget(redoSteps.shift());
29
+ while (totalBytes > limits.bytes && undoSteps.length + redoSteps.length > 1) forget(undoSteps.length > 0 ? undoSteps.shift() : redoSteps.shift());
30
+ }
31
+ function add(steps, step) {
6
32
  steps.push(step);
7
- if (steps.length > limit) steps.shift();
33
+ totalBytes += sizeOf(step);
34
+ stayWithinLimits();
35
+ }
36
+ function take(steps) {
37
+ const step = steps.pop();
38
+ forget(step);
39
+ return step;
8
40
  }
9
41
  return {
10
42
  record(step) {
11
- pushWithinLimit(undoSteps, step);
43
+ redoSteps.forEach(forget);
12
44
  redoSteps.length = 0;
45
+ add(undoSteps, step);
13
46
  },
14
- takeUndo: () => undoSteps.pop(),
15
- takeRedo: () => redoSteps.pop(),
16
- returnUndo: (step) => pushWithinLimit(undoSteps, step),
17
- returnRedo: (step) => pushWithinLimit(redoSteps, step),
47
+ takeUndo: () => take(undoSteps),
48
+ takeRedo: () => take(redoSteps),
49
+ returnUndo: (step) => add(undoSteps, step),
50
+ returnRedo: (step) => add(redoSteps, step),
18
51
  undoLabels: () => undoSteps.map((step) => step.label).reverse(),
19
52
  redoLabels: () => redoSteps.map((step) => step.label).reverse(),
53
+ usedBytes: () => totalBytes,
20
54
  clear() {
21
55
  undoSteps.length = 0;
22
56
  redoSteps.length = 0;
57
+ totalBytes = 0;
23
58
  }
24
59
  };
25
60
  }
26
61
  //#endregion
27
62
  exports.createHistoryStack = createHistoryStack;
63
+ exports.estimateStepBytes = estimateStepBytes;
@@ -1,27 +1,62 @@
1
1
  //#region src/history/history-stack.ts
2
- function createHistoryStack(limit) {
2
+ function bytesOfChange(change) {
3
+ let bytes = (change.order?.length ?? 0) * 8;
4
+ for (const [id, json] of change.objects) bytes += (id.length + (json?.length ?? 0)) * 2;
5
+ return bytes;
6
+ }
7
+ function estimateStepBytes(step) {
8
+ return bytesOfChange(step.before) + bytesOfChange(step.after) + step.label.length * 2;
9
+ }
10
+ function createHistoryStack(limits) {
3
11
  const undoSteps = [];
4
12
  const redoSteps = [];
5
- function pushWithinLimit(steps, step) {
13
+ const sizes = /* @__PURE__ */ new WeakMap();
14
+ let totalBytes = 0;
15
+ function sizeOf(step) {
16
+ let size = sizes.get(step);
17
+ if (size === void 0) {
18
+ size = estimateStepBytes(step);
19
+ sizes.set(step, size);
20
+ }
21
+ return size;
22
+ }
23
+ function forget(step) {
24
+ if (step) totalBytes -= sizeOf(step);
25
+ }
26
+ function stayWithinLimits() {
27
+ while (undoSteps.length > limits.steps) forget(undoSteps.shift());
28
+ while (redoSteps.length > limits.steps) forget(redoSteps.shift());
29
+ while (totalBytes > limits.bytes && undoSteps.length + redoSteps.length > 1) forget(undoSteps.length > 0 ? undoSteps.shift() : redoSteps.shift());
30
+ }
31
+ function add(steps, step) {
6
32
  steps.push(step);
7
- if (steps.length > limit) steps.shift();
33
+ totalBytes += sizeOf(step);
34
+ stayWithinLimits();
35
+ }
36
+ function take(steps) {
37
+ const step = steps.pop();
38
+ forget(step);
39
+ return step;
8
40
  }
9
41
  return {
10
42
  record(step) {
11
- pushWithinLimit(undoSteps, step);
43
+ redoSteps.forEach(forget);
12
44
  redoSteps.length = 0;
45
+ add(undoSteps, step);
13
46
  },
14
- takeUndo: () => undoSteps.pop(),
15
- takeRedo: () => redoSteps.pop(),
16
- returnUndo: (step) => pushWithinLimit(undoSteps, step),
17
- returnRedo: (step) => pushWithinLimit(redoSteps, step),
47
+ takeUndo: () => take(undoSteps),
48
+ takeRedo: () => take(redoSteps),
49
+ returnUndo: (step) => add(undoSteps, step),
50
+ returnRedo: (step) => add(redoSteps, step),
18
51
  undoLabels: () => undoSteps.map((step) => step.label).reverse(),
19
52
  redoLabels: () => redoSteps.map((step) => step.label).reverse(),
53
+ usedBytes: () => totalBytes,
20
54
  clear() {
21
55
  undoSteps.length = 0;
22
56
  redoSteps.length = 0;
57
+ totalBytes = 0;
23
58
  }
24
59
  };
25
60
  }
26
61
  //#endregion
27
- export { createHistoryStack };
62
+ export { createHistoryStack, estimateStepBytes };