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.
- package/README.md +74 -3
- package/dist/assets/asset-pipeline.cjs +3 -1
- package/dist/assets/asset-pipeline.js +3 -1
- package/dist/engine/create-document-engine.cjs +8 -4
- package/dist/engine/create-document-engine.d.cts +5 -0
- package/dist/engine/create-document-engine.d.ts +5 -0
- package/dist/engine/create-document-engine.js +9 -5
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/export/render-export.cjs +3 -15
- package/dist/export/render-export.js +4 -15
- package/dist/history/apply-state.cjs +3 -1
- package/dist/history/apply-state.js +3 -1
- package/dist/history/create-history.cjs +4 -1
- package/dist/history/create-history.d.cts +1 -0
- package/dist/history/create-history.d.ts +1 -0
- package/dist/history/create-history.js +4 -1
- package/dist/history/history-stack.cjs +44 -8
- package/dist/history/history-stack.js +44 -9
- package/dist/index.cjs +6 -0
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/react/engine-context.cjs +12 -0
- package/dist/react/engine-context.d.cts +10 -0
- package/dist/react/engine-context.d.ts +10 -0
- package/dist/react/engine-context.js +11 -0
- package/dist/react/use-document-engine.cjs +23 -0
- package/dist/react/use-document-engine.d.cts +6 -0
- package/dist/react/use-document-engine.d.ts +6 -0
- package/dist/react/use-document-engine.js +23 -0
- package/dist/react/use-document-event.cjs +12 -0
- package/dist/react/use-document-event.d.cts +4 -0
- package/dist/react/use-document-event.d.ts +4 -0
- package/dist/react/use-document-event.js +12 -0
- package/dist/react/use-document-state.cjs +11 -0
- package/dist/react/use-document-state.d.cts +5 -0
- package/dist/react/use-document-state.d.ts +5 -0
- package/dist/react/use-document-state.js +11 -0
- package/dist/react.cjs +11 -0
- package/dist/react.d.cts +6 -0
- package/dist/react.d.ts +6 -0
- package/dist/react.js +6 -0
- package/dist/recovery/recovery-controller.cjs +29 -3
- package/dist/recovery/recovery-controller.js +29 -3
- package/dist/security/content-limits.cjs +86 -0
- package/dist/security/content-limits.d.cts +11 -0
- package/dist/security/content-limits.d.ts +11 -0
- package/dist/security/content-limits.js +84 -0
- package/dist/state/document-state-store.cjs +114 -0
- package/dist/state/document-state-store.d.cts +28 -0
- package/dist/state/document-state-store.d.ts +28 -0
- package/dist/state/document-state-store.js +114 -0
- 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 =
|
|
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 {
|
|
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 =
|
|
420
|
+
const blob = await rendered.content;
|
|
417
421
|
return {
|
|
418
422
|
format,
|
|
419
423
|
mimeType: blob.type || mimeTypes[format],
|
package/dist/engine/errors.d.cts
CHANGED
|
@@ -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;
|
package/dist/engine/errors.d.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 {
|
|
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 (
|
|
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 (
|
|
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(
|
|
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;
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
redoSteps.forEach(forget);
|
|
12
44
|
redoSteps.length = 0;
|
|
45
|
+
add(undoSteps, step);
|
|
13
46
|
},
|
|
14
|
-
takeUndo: () => undoSteps
|
|
15
|
-
takeRedo: () => redoSteps
|
|
16
|
-
returnUndo: (step) =>
|
|
17
|
-
returnRedo: (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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
redoSteps.forEach(forget);
|
|
12
44
|
redoSteps.length = 0;
|
|
45
|
+
add(undoSteps, step);
|
|
13
46
|
},
|
|
14
|
-
takeUndo: () => undoSteps
|
|
15
|
-
takeRedo: () => redoSteps
|
|
16
|
-
returnUndo: (step) =>
|
|
17
|
-
returnRedo: (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 };
|