@bensitu/image-editor 1.2.2 → 1.3.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.2.2
4
+ * @version 1.3.0
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -9,6 +9,12 @@
9
9
 
10
10
  let fabric = null;
11
11
 
12
+ /**
13
+ * Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
14
+ *
15
+ * @returns {typeof globalThis|null} The global scope, or null when no standard scope is available.
16
+ * @private
17
+ */
12
18
  function getGlobalScope() {
13
19
  if (typeof globalThis !== 'undefined') return globalThis;
14
20
  if (typeof self !== 'undefined') return self;
@@ -16,37 +22,96 @@ function getGlobalScope() {
16
22
  return null;
17
23
  }
18
24
 
25
+ /**
26
+ * Returns the globally registered Fabric.js namespace when one is available.
27
+ *
28
+ * @returns {Object|null} The Fabric.js namespace, or null when Fabric is not registered globally.
29
+ * @private
30
+ */
19
31
  function getGlobalFabric() {
20
32
  const scope = getGlobalScope();
21
33
  return scope && scope.fabric ? scope.fabric : null;
22
34
  }
23
35
 
36
+ /**
37
+ * Registers the Fabric.js namespace used by ImageEditor instances.
38
+ *
39
+ * This helper is exported for the package entry wrappers, not as part of the documented package API.
40
+ *
41
+ * @param {Object} [fabricInstance] - Fabric.js namespace object. When omitted, the global `fabric` namespace is used.
42
+ * @returns {Object|null} The active Fabric.js namespace.
43
+ * @private
44
+ */
24
45
  export function setFabric(fabricInstance) {
25
46
  fabric = fabricInstance || getGlobalFabric();
26
47
  return fabric;
27
48
  }
28
49
 
50
+ /**
51
+ * Resolves the active Fabric.js namespace, trying the global namespace as a fallback.
52
+ *
53
+ * @returns {Object|null} The active Fabric.js namespace.
54
+ * @private
55
+ */
29
56
  function ensureFabric() {
30
57
  if (!fabric) setFabric();
31
58
  return fabric;
32
59
  }
33
60
 
61
+ /**
62
+ * @callback ImageLoadedCallback
63
+ * @returns {void}
64
+ */
65
+
66
+ /**
67
+ * @callback EditorErrorCallback
68
+ * @param {*} error - Recoverable error or warning value, when available.
69
+ * @param {string} message - Human-readable context for the error or warning.
70
+ * @returns {void}
71
+ */
72
+
73
+ /**
74
+ * @callback MaskValueResolver
75
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
76
+ * @param {Object} options - Editor options.
77
+ * @returns {number} Resolved numeric mask value.
78
+ */
79
+
80
+ /**
81
+ * @callback MaskFabricGenerator
82
+ * @param {Object} config - Normalized mask configuration.
83
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
84
+ * @param {Object} options - Editor options.
85
+ * @returns {fabric.Object} Custom Fabric object to use as the mask.
86
+ */
87
+
88
+ /**
89
+ * @callback MaskCreateCallback
90
+ * @param {fabric.Object} mask - Created mask object.
91
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
92
+ * @returns {void}
93
+ */
94
+
95
+ /**
96
+ * @callback MaskLabelTextCallback
97
+ * @param {fabric.Object} mask - Mask object whose label is being created.
98
+ * @param {number} creationIndex - Stable zero-based creation index derived from the mask id.
99
+ * @returns {string} Label text.
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} LoadImageOptions
104
+ * @property {boolean} [preserveScroll=false] - If true, keeps the current scroll position while reloading.
105
+ */
106
+
34
107
  /**
35
- * ImageEditor
36
- *
37
- * A lightweight wrapper around fabric.js providing masking, scaling, rotation,
38
- * merging/export helpers, and UI integrations for image editing.
108
+ * Fabric.js-based image editor with masking, transform, crop, history, and export helpers.
39
109
  *
40
- * <b>Note:</b> Requires fabric.js (v5.x) to be loaded on the page before use.
110
+ * Requires Fabric.js v5.x through the ESM package entry or a globally available `fabric` namespace.
41
111
  *
42
- * <pre>
43
- * Example usage:
112
+ * @example
44
113
  * const editor = new ImageEditor({ canvasWidth: 1024, canvasHeight: 768 });
45
114
  * editor.init();
46
- * </pre>
47
- *
48
- * @class ImageEditor
49
- * @classdesc Fabric.js-based image editor with simple mask, transform, export and UI features.
50
115
  *
51
116
  * @param {Object} [options={}] - Customization options to override defaults.
52
117
  * @param {number} [options.canvasWidth=800] - The initial canvas width in pixels.
@@ -57,33 +122,36 @@ function ensureFabric() {
57
122
  * @param {number} [options.maxScale=5.0] - Maximum image scaling factor.
58
123
  * @param {number} [options.scaleStep=0.05] - Scale increment/decrement per step.
59
124
  * @param {number} [options.rotationStep=90] - Rotation step in degrees.
60
- * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image/mask.
125
+ * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit the loaded image.
61
126
  * @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
62
127
  * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
63
128
  * @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
64
129
  * @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
65
130
  * @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
66
131
  * @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
132
+ * @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
67
133
  * @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
68
134
  * @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
69
- * @param {number} [options.defaultMaskWidth=50] - Default width of new mask rectangles.
135
+ * @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
70
136
  * @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
71
137
  * @param {boolean} [options.maskRotatable=false] - If true, masks can be rotated.
72
138
  * @param {boolean} [options.maskLabelOnSelect=true] - Show label on selected mask.
73
139
  * @param {number} [options.maskLabelOffset=3] - Offset for mask labels from top-left corner.
74
140
  * @param {string} [options.maskName='mask'] - Prefix for mask names/labels.
141
+ * @param {boolean} [options.groupSelection=false] - If true, Fabric can select multiple masks as an ActiveSelection.
75
142
  * @param {boolean} [options.showPlaceholder=true] - If true, shows placeholder when no image is loaded.
76
143
  * @param {string|null} [options.initialImageBase64=null] - Base64 string to auto-load as initial image, if any.
77
144
  * @param {string} [options.defaultDownloadFileName='edited_image.jpg'] - Default file name for downloads.
78
- * @param {function} [options.onImageLoaded] - Optional callback to invoke after an image loads.
79
- * @param {function} [options.onError] - Optional callback for recoverable internal errors.
80
- * @param {function} [options.onWarning] - Optional callback for recoverable internal warnings.
81
- *
82
- * @constructor
145
+ * @param {Object} [options.label] - Mask label customization options.
146
+ * @param {MaskLabelTextCallback} [options.label.getText] - Callback for label text; receives a stable zero-based creation index.
147
+ * @param {Object} [options.crop] - Crop mode customization options.
148
+ * @param {ImageLoadedCallback} [options.onImageLoaded] - Callback invoked after an image load completes.
149
+ * @param {EditorErrorCallback} [options.onError] - Callback invoked for recoverable internal errors.
150
+ * @param {EditorErrorCallback} [options.onWarning] - Callback invoked for recoverable internal warnings.
83
151
  */
84
152
  class ImageEditor {
85
153
  constructor(options = {}) {
86
- // Default options (can be overridden via ctor param)
154
+ // Default options that callers can override with constructor options.
87
155
  const defaultLabel = {
88
156
  getText: (mask) => mask.maskName,
89
157
  textOptions: {
@@ -128,6 +196,7 @@ function ensureFabric() {
128
196
  downsampleMaxWidth: 4000,
129
197
  downsampleMaxHeight: 3000,
130
198
  downsampleQuality: 0.92,
199
+ imageLoadTimeoutMs: 30000,
131
200
 
132
201
  exportMultiplier: 1,
133
202
  exportImageAreaByDefault: true,
@@ -163,19 +232,19 @@ function ensureFabric() {
163
232
  }
164
233
  };
165
234
 
166
- // Verify that fabric.js is present
235
+ // Verify that Fabric.js is present before any canvas work starts.
167
236
  this._fabricLoaded = !!ensureFabric();
168
237
  if (!this._fabricLoaded) {
169
238
  this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
170
239
  }
171
240
 
172
- // Runtime state
241
+ // Runtime state owned by this editor instance.
173
242
  this.canvas = null;
174
243
  this.canvasElement = null;
175
244
  this.containerElement = null;
176
245
  this.placeholderElement = null;
177
246
 
178
- this.originalImage = null; // fabric.Image
247
+ this.originalImage = null;
179
248
  this.baseImageScale = 1;
180
249
  this.currentScale = 1;
181
250
  this.currentRotation = 0;
@@ -199,15 +268,19 @@ function ensureFabric() {
199
268
  this._cropPrevEvented = null;
200
269
  this._prevSelectionSetting = undefined;
201
270
  this._containerOriginalOverflow = undefined;
271
+ this._scrollbarSizeCache = null;
202
272
 
203
273
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
204
274
 
205
- this.animQueue = new AnimationQueue();
275
+ this.animationQueue = new AnimationQueue();
206
276
  this.historyManager = new HistoryManager(this.maxHistorySize);
207
277
  }
208
278
 
209
279
  /**
210
- * @deprecated Use canvasElement instead.
280
+ * Backward-compatible alias for {@link ImageEditor#canvasElement}.
281
+ *
282
+ * @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
283
+ * @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
211
284
  */
212
285
  get canvasEl() {
213
286
  return this.canvasElement;
@@ -218,7 +291,10 @@ function ensureFabric() {
218
291
  }
219
292
 
220
293
  /**
221
- * @deprecated Use containerElement instead.
294
+ * Backward-compatible alias for {@link ImageEditor#containerElement}.
295
+ *
296
+ * @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
297
+ * @returns {HTMLElement|null} The canvas viewport/container element.
222
298
  */
223
299
  get containerEl() {
224
300
  return this.containerElement;
@@ -229,7 +305,10 @@ function ensureFabric() {
229
305
  }
230
306
 
231
307
  /**
232
- * @deprecated Use placeholderElement instead.
308
+ * Backward-compatible alias for {@link ImageEditor#placeholderElement}.
309
+ *
310
+ * @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
311
+ * @returns {HTMLElement|null} The placeholder element shown before an image loads.
233
312
  */
234
313
  get placeholderEl() {
235
314
  return this.placeholderElement;
@@ -245,9 +324,10 @@ function ensureFabric() {
245
324
  * Use this method to set up the editor UI before interacting with it.
246
325
  *
247
326
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
248
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput, rotationRightInput,
249
- * rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn, mergeBtn, downloadBtn, maskList,
250
- * zoomInBtn, zoomOutBtn, resetBtn, imageInput. Unknown keys are ignored.
327
+ * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
328
+ * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
329
+ * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
330
+ * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
251
331
  *
252
332
  * @returns {void}
253
333
  *
@@ -327,7 +407,9 @@ function ensureFabric() {
327
407
  }
328
408
 
329
409
  /**
330
- * Canvas setup helpers
410
+ * Initializes the Fabric canvas, viewport elements, and selection event handlers.
411
+ *
412
+ * @returns {void}
331
413
  * @private
332
414
  */
333
415
  _initCanvas() {
@@ -335,7 +417,7 @@ function ensureFabric() {
335
417
  if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
336
418
  this.canvasElement = canvasElement;
337
419
 
338
- // Decide which element acts as "viewport" (for width/height fallback)
420
+ // Decide which element acts as the viewport for size fallback and scrolling.
339
421
  if (this.elements.canvasContainer) {
340
422
  const containerElement = document.getElementById(this.elements.canvasContainer);
341
423
  this.containerElement = containerElement || canvasElement.parentElement;
@@ -345,7 +427,7 @@ function ensureFabric() {
345
427
 
346
428
  this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
347
429
 
348
- // Initial size take container size if available
430
+ // Prefer a measured container size when it is available.
349
431
  let initialWidth = this.options.canvasWidth;
350
432
  let initialHeight = this.options.canvasHeight;
351
433
  if (this.containerElement) {
@@ -365,7 +447,7 @@ function ensureFabric() {
365
447
  preserveObjectStacking: true
366
448
  });
367
449
 
368
- // Fabric event wiring
450
+ // Fabric event wiring keeps selection, mask labels, and history in sync.
369
451
  this.canvas.on('selection:created', (event) => this._handleSelectionChanged(event.selected));
370
452
  this.canvas.on('selection:updated', (event) => this._handleSelectionChanged(event.selected));
371
453
  this.canvas.on('selection:cleared', () => this._handleSelectionChanged([]));
@@ -374,21 +456,35 @@ function ensureFabric() {
374
456
  this.canvas.on('object:rotating', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
375
457
  this.canvas.on('object:modified', (event) => this._handleObjectModified(event.target));
376
458
 
377
- // Avoid inline-element whitespace artefacts
459
+ // Avoid inline-element whitespace artifacts around the canvas.
378
460
  this.canvasElement.style.display = 'block';
379
461
  }
380
462
 
463
+ /**
464
+ * Records a history entry after Fabric finishes modifying one or more masks.
465
+ *
466
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
467
+ * @returns {void}
468
+ * @private
469
+ */
381
470
  _handleObjectModified(target) {
382
471
  const masks = this._getModifiedMasks(target);
383
472
  if (!masks.length) return;
384
473
  masks.forEach(mask => {
385
474
  if (typeof mask.setCoords === 'function') mask.setCoords();
386
475
  this._syncMaskLabel(mask);
387
- this._expandCanvasToFitObject(mask);
388
476
  });
477
+ this._expandCanvasToFitObjects(masks);
389
478
  this.saveState();
390
479
  }
391
480
 
481
+ /**
482
+ * Extracts editable mask objects from a Fabric modification target.
483
+ *
484
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
485
+ * @returns {Array<fabric.Object>} Modified mask objects.
486
+ * @private
487
+ */
392
488
  _getModifiedMasks(target) {
393
489
  if (!target) return [];
394
490
  if (target.maskId) return [target];
@@ -398,23 +494,33 @@ function ensureFabric() {
398
494
  return Array.isArray(objects) ? objects.filter(object => object && object.maskId) : [];
399
495
  }
400
496
 
401
- _syncContainerOverflow() {
497
+ /**
498
+ * Updates container overflow behavior for fit and cover image modes.
499
+ *
500
+ * @param {Object} [options={}] - Overflow update options.
501
+ * @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
502
+ * @returns {void}
503
+ * @private
504
+ */
505
+ _syncContainerOverflow(options = {}) {
402
506
  if (!this.containerElement || !this.containerElement.style) return;
403
507
  if (this._containerOriginalOverflow === undefined) {
404
508
  this._containerOriginalOverflow = this.containerElement.style.overflow || '';
405
509
  }
406
510
 
511
+ const shouldPreserveScroll = options.preserveScroll === true;
407
512
  if (this.options.coverImageToCanvas) {
408
- const shouldResetScroll = !this.isImageLoadedToCanvas;
409
513
  this.containerElement.style.overflow = 'scroll';
410
- if (shouldResetScroll) {
514
+ if (!shouldPreserveScroll) {
411
515
  this.containerElement.scrollLeft = 0;
412
516
  this.containerElement.scrollTop = 0;
413
517
  }
414
518
  } else if (this.options.fitImageToCanvas) {
415
519
  this.containerElement.style.overflow = 'auto';
416
- this.containerElement.scrollLeft = 0;
417
- this.containerElement.scrollTop = 0;
520
+ if (!shouldPreserveScroll) {
521
+ this.containerElement.scrollLeft = 0;
522
+ this.containerElement.scrollTop = 0;
523
+ }
418
524
  } else {
419
525
  this.containerElement.style.overflow = this._containerOriginalOverflow;
420
526
  }
@@ -477,12 +583,12 @@ function ensureFabric() {
477
583
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
478
584
  }
479
585
 
480
- /**
481
- * Event binding element check
482
- *
483
- * @param {*} eventName
484
- * @param {*} handler
485
- * @param {*} key
586
+ /**
587
+ * Binds a DOM event listener when the configured element exists and records it for disposal.
588
+ *
589
+ * @param {string} key - Key in this.elements for the target DOM element.
590
+ * @param {string} eventName - DOM event name to listen for.
591
+ * @param {EventListener} handler - Event listener callback.
486
592
  * @private
487
593
  */
488
594
  _bindIfExists(key, eventName, handler) {
@@ -495,10 +601,10 @@ function ensureFabric() {
495
601
  }
496
602
  }
497
603
 
498
- /**
499
- * Image loading helpers
500
- *
501
- * @param {File} file
604
+ /**
605
+ * Reads an image File as a data URL and loads it into the Fabric canvas.
606
+ *
607
+ * @param {File} file - Image file selected by the user.
502
608
  * @private
503
609
  */
504
610
  _loadImageFile(file) {
@@ -510,17 +616,41 @@ function ensureFabric() {
510
616
  }
511
617
 
512
618
  /**
513
- * Load a base64 encoded image string into fabric.
514
- * @async
515
- * @param {String} imageBase64
619
+ * Warns when more than one mutually exclusive image layout mode is enabled.
620
+ *
621
+ * @returns {void}
622
+ * @private
623
+ */
624
+ _warnOnImageLayoutOptionConflict() {
625
+ const activeModes = [
626
+ ['fitImageToCanvas', this.options.fitImageToCanvas],
627
+ ['coverImageToCanvas', this.options.coverImageToCanvas],
628
+ ['expandCanvasToImage', this.options.expandCanvasToImage]
629
+ ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
630
+
631
+ if (activeModes.length <= 1) return;
632
+ this._reportWarning(
633
+ `Only one image layout mode should be enabled. Active modes: ${activeModes.join(', ')}.`
634
+ );
635
+ }
636
+
637
+ /**
638
+ * Loads a base64 data URL into the Fabric canvas as the base image.
639
+ *
640
+ * @async
641
+ * @param {string} imageBase64 - Image data URL beginning with `data:image/`.
642
+ * @param {LoadImageOptions} [options={}] - Optional load behavior.
643
+ * @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
644
+ * @public
516
645
  */
517
- async loadImage(imageBase64) {
646
+ async loadImage(imageBase64, options = {}) {
518
647
  if (!this._fabricLoaded) return;
519
648
  if (!this.canvas) return;
520
649
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
521
650
 
651
+ this._warnOnImageLayoutOptionConflict();
522
652
  this._setPlaceholderVisible(false);
523
- this._syncContainerOverflow();
653
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
524
654
 
525
655
  const imageElement = await this._createImageElement(imageBase64);
526
656
 
@@ -561,9 +691,9 @@ function ensureFabric() {
561
691
  const minHeight = viewport.height;
562
692
 
563
693
  if (this.options.fitImageToCanvas) {
564
- // Fit into current canvas (shrink only) and ensure canvas does not exceed container
565
- const canvasWidth = Math.max(1, Math.min(this.options.canvasWidth, minWidth) - 1)
566
- const canvasHeight = Math.max(1, Math.min(this.options.canvasHeight, minHeight) - 1);
694
+ // Fit into the visible viewport, shrinking only when the image is larger.
695
+ const canvasWidth = Math.max(1, minWidth - 1);
696
+ const canvasHeight = Math.max(1, minHeight - 1);
567
697
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
568
698
  const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
569
699
  fabricImage.set({ left: 0, top: 0 });
@@ -650,22 +780,32 @@ function ensureFabric() {
650
780
  * Creates an HTMLImageElement from a given data URL.
651
781
  *
652
782
  * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
783
+ * @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
653
784
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
654
785
  * @private
655
786
  */
656
- _createImageElement(dataUrl) {
787
+ _createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
657
788
  return new Promise((resolve, reject) => {
658
789
  const imageElement = new Image();
659
- imageElement.onload = () => {
790
+ let isSettled = false;
791
+ const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
792
+ ? Number(timeoutMs)
793
+ : 30000;
794
+ let timerId;
795
+ const settle = (callback) => {
796
+ if (isSettled) return;
797
+ isSettled = true;
798
+ clearTimeout(timerId);
660
799
  imageElement.onload = null;
661
800
  imageElement.onerror = null;
662
- resolve(imageElement);
663
- };
664
- imageElement.onerror = (error) => {
665
- imageElement.onload = null;
666
- imageElement.onerror = null;
667
- reject(error);
801
+ callback();
668
802
  };
803
+ timerId = setTimeout(() => {
804
+ settle(() => reject(new Error('Image load timed out')));
805
+ try { imageElement.src = ''; } catch (error) { void error; }
806
+ }, safeTimeoutMs);
807
+ imageElement.onload = () => settle(() => resolve(imageElement));
808
+ imageElement.onerror = (error) => settle(() => reject(error));
669
809
  imageElement.src = dataUrl;
670
810
  });
671
811
  }
@@ -685,6 +825,7 @@ function ensureFabric() {
685
825
  offscreenCanvas.width = targetWidth;
686
826
  offscreenCanvas.height = targetHeight;
687
827
  const context = offscreenCanvas.getContext('2d');
828
+ if (!context) throw new Error('2D canvas context is unavailable');
688
829
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
689
830
  return offscreenCanvas.toDataURL('image/jpeg', quality);
690
831
  }
@@ -693,21 +834,21 @@ function ensureFabric() {
693
834
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
694
835
  * Also updates the corresponding style attributes.
695
836
  *
696
- * @param {number} w - Canvas width (in pixels).
697
- * @param {number} h - Canvas height (in pixels).
837
+ * @param {number} width - Canvas width in pixels.
838
+ * @param {number} height - Canvas height in pixels.
698
839
  * @private
699
840
  */
700
- _setCanvasSizeInt(w, h) {
701
- const iw = Math.max(1, Math.round(Number(w) || 1));
702
- const ih = Math.max(1, Math.round(Number(h) || 1));
841
+ _setCanvasSizeInt(width, height) {
842
+ const integerWidth = Math.max(1, Math.round(Number(width) || 1));
843
+ const integerHeight = Math.max(1, Math.round(Number(height) || 1));
703
844
  // Set fabric internal and also style attributes to keep DOM consistent
704
- this.canvas.setWidth(iw);
705
- this.canvas.setHeight(ih);
845
+ this.canvas.setWidth(integerWidth);
846
+ this.canvas.setHeight(integerHeight);
706
847
  if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
707
848
  // Keep DOM element in sync (avoid fractional CSS pixels)
708
849
  if (this.canvasElement) {
709
- this.canvasElement.style.width = iw + 'px';
710
- this.canvasElement.style.height = ih + 'px';
850
+ this.canvasElement.style.width = integerWidth + 'px';
851
+ this.canvasElement.style.height = integerHeight + 'px';
711
852
  this.canvasElement.style.maxWidth = 'none';
712
853
  }
713
854
  }
@@ -734,13 +875,9 @@ function ensureFabric() {
734
875
  };
735
876
  }
736
877
 
737
- const previousOverflow = this.containerElement.style.overflow;
738
- this.containerElement.style.overflow = 'hidden';
739
-
740
878
  const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
741
879
  const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
742
880
 
743
- this.containerElement.style.overflow = previousOverflow;
744
881
  return { width, height };
745
882
  }
746
883
 
@@ -765,6 +902,9 @@ function ensureFabric() {
765
902
  }
766
903
 
767
904
  _getScrollbarSize() {
905
+ if (this._scrollbarSizeCache) {
906
+ return { ...this._scrollbarSizeCache };
907
+ }
768
908
  if (typeof document === 'undefined' || !document.createElement || !document.body) {
769
909
  return { width: 0, height: 0 };
770
910
  }
@@ -782,7 +922,8 @@ function ensureFabric() {
782
922
  const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
783
923
  document.body.removeChild(probe);
784
924
 
785
- return { width, height };
925
+ this._scrollbarSizeCache = { width, height };
926
+ return { ...this._scrollbarSizeCache };
786
927
  }
787
928
 
788
929
  _getScrollSafetyMargin() {
@@ -964,6 +1105,27 @@ function ensureFabric() {
964
1105
  if (typeof mask.setCoords === 'function') mask.setCoords();
965
1106
  }
966
1107
 
1108
+ /**
1109
+ * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1110
+ *
1111
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1112
+ * @private
1113
+ */
1114
+ _serializeEditorMetadata() {
1115
+ const baseImageScale = Number(this.baseImageScale);
1116
+ const currentScale = Number(this.currentScale);
1117
+ const currentRotation = Number(this.currentRotation);
1118
+ const maskCounter = Number(this.maskCounter);
1119
+
1120
+ return {
1121
+ version: 1,
1122
+ baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1123
+ currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1124
+ currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1125
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1126
+ };
1127
+ }
1128
+
967
1129
  _serializeCanvasState() {
968
1130
  if (!this.canvas) return null;
969
1131
  return this._withNormalizedMaskStyles(() => {
@@ -971,16 +1133,31 @@ function ensureFabric() {
971
1133
  if (Array.isArray(jsonObject.objects)) {
972
1134
  jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
973
1135
  }
1136
+ jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
974
1137
  return JSON.stringify(jsonObject);
975
1138
  });
976
1139
  }
977
1140
 
1141
+ /**
1142
+ * Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
1143
+ *
1144
+ * @param {number} quality - Requested image quality.
1145
+ * @returns {number} A finite quality value between 0 and 1.
1146
+ * @private
1147
+ */
978
1148
  _normalizeQuality(quality) {
979
1149
  const numericQuality = Number(quality);
980
1150
  if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
981
1151
  return Math.max(0, Math.min(1, numericQuality));
982
1152
  }
983
1153
 
1154
+ /**
1155
+ * Normalizes public image format aliases to canvas export format names.
1156
+ *
1157
+ * @param {string} format - Requested image format or MIME type.
1158
+ * @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
1159
+ * @private
1160
+ */
984
1161
  _normalizeImageFormat(format) {
985
1162
  const typeMapping = {
986
1163
  'jpeg': 'jpeg',
@@ -994,6 +1171,15 @@ function ensureFabric() {
994
1171
  return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
995
1172
  }
996
1173
 
1174
+ /**
1175
+ * Converts a bounding rectangle into a canvas-safe integer source region.
1176
+ *
1177
+ * @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
1178
+ * @param {Object} [options={}] - Region rounding options.
1179
+ * @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
1180
+ * @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
1181
+ * @private
1182
+ */
997
1183
  _getClampedCanvasRegion(bounds, options = {}) {
998
1184
  const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
999
1185
  const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
@@ -1009,16 +1195,46 @@ function ensureFabric() {
1009
1195
  const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
1010
1196
 
1011
1197
  return {
1012
- sx: sourceX,
1013
- sy: sourceY,
1014
- sw: Math.max(1, endX - sourceX),
1015
- sh: Math.max(1, endY - sourceY)
1198
+ sourceX,
1199
+ sourceY,
1200
+ sourceWidth: Math.max(1, endX - sourceX),
1201
+ sourceHeight: Math.max(1, endY - sourceY)
1016
1202
  };
1017
1203
  }
1018
1204
 
1205
+ /**
1206
+ * Crops an image data URL to a source region using an offscreen canvas.
1207
+ *
1208
+ * @param {string} dataUrl - Source image data URL.
1209
+ * @param {number} sourceX - Source region x coordinate.
1210
+ * @param {number} sourceY - Source region y coordinate.
1211
+ * @param {number} sourceWidth - Source region width.
1212
+ * @param {number} sourceHeight - Source region height.
1213
+ * @param {number} multiplier - Export multiplier already applied to the source data URL.
1214
+ * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1215
+ * @param {number} [quality=0.92] - Output image quality for lossy formats.
1216
+ * @returns {Promise<string>} Resolves with the cropped image data URL.
1217
+ * @private
1218
+ */
1019
1219
  async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1020
1220
  return new Promise((resolve, reject) => {
1021
1221
  const imageElement = new Image();
1222
+ let isSettled = false;
1223
+ const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1224
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
1225
+ let timerId;
1226
+ const settle = (callback) => {
1227
+ if (isSettled) return;
1228
+ isSettled = true;
1229
+ clearTimeout(timerId);
1230
+ imageElement.onload = null;
1231
+ imageElement.onerror = null;
1232
+ callback();
1233
+ };
1234
+ timerId = setTimeout(() => {
1235
+ settle(() => reject(new Error('Image crop load timed out')));
1236
+ try { imageElement.src = ''; } catch (error) { void error; }
1237
+ }, safeTimeoutMs);
1022
1238
  imageElement.onload = () => {
1023
1239
  try {
1024
1240
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
@@ -1030,19 +1246,34 @@ function ensureFabric() {
1030
1246
  offscreenCanvas.width = scaledSourceWidth;
1031
1247
  offscreenCanvas.height = scaledSourceHeight;
1032
1248
  const context = offscreenCanvas.getContext('2d');
1249
+ if (!context) throw new Error('2D canvas context is unavailable');
1033
1250
 
1034
1251
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1035
- resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
1252
+ settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1036
1253
  } catch (error) {
1037
- reject(error);
1254
+ settle(() => reject(error));
1038
1255
  }
1039
1256
  };
1040
- imageElement.onerror = reject;
1257
+ imageElement.onerror = (error) => settle(() => reject(error));
1041
1258
  imageElement.src = dataUrl;
1042
1259
  });
1043
1260
  }
1044
1261
 
1045
- async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1262
+ /**
1263
+ * Exports the whole Fabric canvas, then crops the requested source region from that export.
1264
+ *
1265
+ * @param {Object} region - Canvas source region and export options.
1266
+ * @param {number} region.sourceX - Source region x coordinate.
1267
+ * @param {number} region.sourceY - Source region y coordinate.
1268
+ * @param {number} region.sourceWidth - Source region width.
1269
+ * @param {number} region.sourceHeight - Source region height.
1270
+ * @param {number} [region.multiplier=1] - Export multiplier.
1271
+ * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1272
+ * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1273
+ * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1274
+ * @private
1275
+ */
1276
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1046
1277
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1047
1278
  const fullDataUrl = this.canvas.toDataURL({
1048
1279
  format,
@@ -1050,7 +1281,7 @@ function ensureFabric() {
1050
1281
  multiplier: safeMultiplier
1051
1282
  });
1052
1283
 
1053
- return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
1284
+ return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1054
1285
  }
1055
1286
 
1056
1287
  /**
@@ -1117,23 +1348,60 @@ function ensureFabric() {
1117
1348
  this._setCanvasSizeInt(size.width, size.height);
1118
1349
  }
1119
1350
 
1120
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1121
- if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage) return;
1351
+ /**
1352
+ * Whether post-load edits should resize the canvas to keep transformed content visible.
1353
+ *
1354
+ * @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
1355
+ * @private
1356
+ */
1357
+ _shouldResizeCanvasToContentBounds() {
1358
+ return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
1359
+ }
1360
+
1361
+ /**
1362
+ * Expands the canvas once so all provided objects remain visible after an edit.
1363
+ *
1364
+ * @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
1365
+ * @param {number} [padding=10] - Extra canvas space after the farthest object edge.
1366
+ * @returns {void}
1367
+ * @private
1368
+ */
1369
+ _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1370
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1122
1371
  try {
1123
- fabricObject.setCoords();
1124
- const boundingRect = fabricObject.getBoundingRect(true, true);
1125
- const requiredWidth = Math.ceil(boundingRect.left + boundingRect.width + padding);
1126
- const requiredHeight = Math.ceil(boundingRect.top + boundingRect.height + padding);
1372
+ let requiredWidth = this.canvas.getWidth();
1373
+ let requiredHeight = this.canvas.getHeight();
1374
+ fabricObjects.forEach(fabricObject => {
1375
+ if (!fabricObject) return;
1376
+ if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1377
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1378
+ requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1379
+ requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1380
+ });
1127
1381
  const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1128
1382
  const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1129
1383
  const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1130
1384
  const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1131
- this._setCanvasSizeInt(newWidth, newHeight);
1385
+ if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1386
+ this._setCanvasSizeInt(newWidth, newHeight);
1387
+ }
1132
1388
  } catch (error) {
1133
- this._reportWarning('expandCanvasToFitObject: failed to expand canvas', error);
1389
+ this._reportWarning('expandCanvasToFitObjects: failed to expand canvas', error);
1134
1390
  }
1135
1391
  }
1136
1392
 
1393
+ /**
1394
+ * Expands the canvas so one object remains visible after an edit.
1395
+ *
1396
+ * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1397
+ * @param {number} [padding=10] - Extra canvas space after the object edge.
1398
+ * @returns {void}
1399
+ * @private
1400
+ */
1401
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1402
+ this._expandCanvasToFitObjects([fabricObject], padding);
1403
+ }
1404
+
1137
1405
  /**
1138
1406
  * Scales the original image by a given factor, with animation.
1139
1407
  * Returns a promise that resolves when the scale animation is complete.
@@ -1142,7 +1410,7 @@ function ensureFabric() {
1142
1410
  * @public
1143
1411
  */
1144
1412
  scaleImage(factor, options = {}) {
1145
- return this.animQueue.add(() => this._scaleImageImpl(factor, options));
1413
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1146
1414
  }
1147
1415
 
1148
1416
  /**
@@ -1186,7 +1454,7 @@ function ensureFabric() {
1186
1454
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1187
1455
  this.originalImage.setCoords();
1188
1456
 
1189
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1457
+ if (this._shouldResizeCanvasToContentBounds()) {
1190
1458
  this._updateCanvasSizeToImageBounds();
1191
1459
  }
1192
1460
 
@@ -1213,7 +1481,7 @@ function ensureFabric() {
1213
1481
  * @public
1214
1482
  */
1215
1483
  rotateImage(degrees, options = {}) {
1216
- return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
1484
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1217
1485
  }
1218
1486
 
1219
1487
  /**
@@ -1247,7 +1515,7 @@ function ensureFabric() {
1247
1515
  this.originalImage.set('angle', degrees);
1248
1516
  this.originalImage.setCoords();
1249
1517
 
1250
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1518
+ if (this._shouldResizeCanvasToContentBounds()) {
1251
1519
  this._updateCanvasSizeToImageBounds();
1252
1520
  }
1253
1521
 
@@ -1271,43 +1539,52 @@ function ensureFabric() {
1271
1539
 
1272
1540
  /**
1273
1541
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
1274
- * @returns {Promise<void>} Promise that resolves when reset is complete.
1542
+ *
1543
+ * @returns {Promise<void>} Resolves when the reset history transition has been recorded.
1544
+ * @public
1275
1545
  */
1276
1546
  resetImageTransform() {
1277
1547
  if (!this.originalImage) return Promise.resolve();
1278
1548
 
1279
- return this.animQueue.add(async () => {
1280
- const before = this._serializeCanvasState();
1549
+ return this.animationQueue.add(async () => {
1550
+ const before = this._lastSnapshot || this._serializeCanvasState();
1281
1551
  await this._scaleImageImpl(1, { saveHistory: false });
1282
1552
  await this._rotateImageImpl(0, { saveHistory: false });
1283
1553
  const after = this._serializeCanvasState();
1284
1554
  this._pushStateTransition(before, after);
1285
- }).catch(err => {
1286
- this._reportError('resetImageTransform() failed', err);
1555
+ }).catch(error => {
1556
+ this._reportError('resetImageTransform() failed', error);
1287
1557
  });
1288
1558
  }
1289
1559
 
1290
1560
  /**
1291
- * @deprecated Use resetImageTransform() instead.
1561
+ * Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
1562
+ *
1563
+ * @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
1564
+ * @returns {Promise<void>} Resolves when the image transform reset is complete.
1292
1565
  */
1293
1566
  reset() {
1294
1567
  return this.resetImageTransform();
1295
1568
  }
1296
1569
 
1297
1570
  /**
1298
- * Restores a canvas state that was previously stored by saveState().
1299
- * @param {string} jsonString - the JSON string returned by fabric.toJSON().
1571
+ * Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
1572
+ *
1573
+ * @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
1574
+ * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1575
+ * @public
1300
1576
  */
1301
- loadFromState(jsonString) {
1302
- if (!jsonString || !this.canvas) return Promise.resolve();
1577
+ loadFromState(serializedState) {
1578
+ if (!serializedState || !this.canvas) return Promise.resolve();
1303
1579
 
1304
1580
  return new Promise((resolve) => {
1305
1581
  try {
1306
- const json = (typeof jsonString === 'string')
1307
- ? JSON.parse(jsonString)
1308
- : jsonString;
1582
+ const state = (typeof serializedState === 'string')
1583
+ ? JSON.parse(serializedState)
1584
+ : serializedState;
1585
+ const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1309
1586
 
1310
- this.canvas.loadFromJSON(json, () => {
1587
+ this.canvas.loadFromJSON(state, () => {
1311
1588
  try {
1312
1589
  this._hideAllMaskLabels();
1313
1590
  const canvasObjects = this.canvas.getObjects();
@@ -1316,11 +1593,27 @@ function ensureFabric() {
1316
1593
  if (this.originalImage) {
1317
1594
  this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
1318
1595
  this.canvas.sendToBack(this.originalImage);
1319
- this.currentRotation = Number(this.originalImage.angle) || 0;
1320
- const baseScale = Number(this.baseImageScale) || 1;
1321
- const imageScale = Number(this.originalImage.scaleX) || baseScale;
1322
- this.currentScale = imageScale / baseScale;
1596
+ const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
1597
+ const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
1598
+ const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
1599
+
1600
+ if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
1601
+ this.baseImageScale = restoredBaseScale;
1602
+ }
1603
+
1604
+ if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
1605
+ this.currentScale = restoredCurrentScale;
1606
+ } else {
1607
+ const baseScale = Number(this.baseImageScale) || 1;
1608
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1609
+ this.currentScale = imageScale / baseScale;
1610
+ }
1611
+
1612
+ this.currentRotation = Number.isFinite(restoredCurrentRotation)
1613
+ ? restoredCurrentRotation
1614
+ : (Number(this.originalImage.angle) || 0);
1323
1615
  } else {
1616
+ this.baseImageScale = 1;
1324
1617
  this.currentScale = 1;
1325
1618
  this.currentRotation = 0;
1326
1619
  }
@@ -1331,8 +1624,12 @@ function ensureFabric() {
1331
1624
  this._rebindMaskEvents(mask);
1332
1625
  mask.set(this._getMaskNormalStyle(mask));
1333
1626
  });
1334
- this.maskCounter = masks.reduce((max, mask) =>
1627
+ const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
1628
+ const maxMaskId = masks.reduce((max, mask) =>
1335
1629
  Math.max(max, mask.maskId), 0);
1630
+ this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId
1631
+ ? Math.floor(restoredMaskCounter)
1632
+ : maxMaskId;
1336
1633
  this._lastMask = masks.length ? masks[masks.length - 1] : null;
1337
1634
  if (!this._lastMask) {
1338
1635
  this._lastMaskInitialLeft = null;
@@ -1362,12 +1659,17 @@ function ensureFabric() {
1362
1659
  }
1363
1660
 
1364
1661
  /**
1365
- * Saves the current state of the canvas to history, storing any mask/raster label information.
1662
+ * Saves the current editable canvas state as an undoable history transition.
1663
+ *
1664
+ * Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
1665
+ * mask objects and restored by `loadFromState()`.
1666
+ *
1667
+ * @returns {void}
1668
+ * @public
1366
1669
  */
1367
1670
  saveState() {
1368
1671
  if (!this.canvas) return;
1369
1672
  const activeObject = this.canvas.getActiveObject();
1370
- this._hideAllMaskLabels();
1371
1673
 
1372
1674
  try {
1373
1675
  const after = this._serializeCanvasState();
@@ -1391,13 +1693,24 @@ function ensureFabric() {
1391
1693
  } catch (error) {
1392
1694
  this._reportWarning('saveState: failed to save canvas snapshot', error);
1393
1695
  } finally {
1394
- if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
1696
+ if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1395
1697
  this._handleSelectionChanged([activeObject]);
1396
1698
  }
1397
1699
  this._updateUI();
1398
1700
  }
1399
1701
  }
1400
1702
 
1703
+ /**
1704
+ * Pushes a precomputed before/after state transition into history.
1705
+ *
1706
+ * Use this for operations such as crop and merge that build their snapshots around asynchronous image
1707
+ * loading, where the "after" state is already applied before the history command is recorded.
1708
+ *
1709
+ * @param {string} before - Serialized state before the operation.
1710
+ * @param {string} after - Serialized state after the operation.
1711
+ * @returns {void}
1712
+ * @private
1713
+ */
1401
1714
  _pushStateTransition(before, after) {
1402
1715
  if (!before || !after) return;
1403
1716
  if (before === after) return;
@@ -1414,6 +1727,9 @@ function ensureFabric() {
1414
1727
 
1415
1728
  /**
1416
1729
  * Undo the last state change, if possible.
1730
+ *
1731
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
1732
+ * @public
1417
1733
  */
1418
1734
  undo() {
1419
1735
  return this.historyManager.undo()
@@ -1423,6 +1739,9 @@ function ensureFabric() {
1423
1739
 
1424
1740
  /**
1425
1741
  * Redo the next state change, if possible.
1742
+ *
1743
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
1744
+ * @public
1426
1745
  */
1427
1746
  redo() {
1428
1747
  return this.historyManager.redo()
@@ -1436,7 +1755,7 @@ function ensureFabric() {
1436
1755
  try {
1437
1756
  mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
1438
1757
  mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
1439
- } catch (e) { void e; }
1758
+ } catch (error) { void error; }
1440
1759
  }
1441
1760
 
1442
1761
  const metadata = {};
@@ -1474,29 +1793,38 @@ function ensureFabric() {
1474
1793
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
1475
1794
  }
1476
1795
 
1477
- /**
1796
+ /**
1478
1797
  * Creates a mask and adds it to the canvas.
1479
- * Mask placement and properties are determined by the provided config and instance options.
1480
- * Canvas and list UI are updated accordingly.
1481
- * @param {Object} [config={}] - Optional mask configuration overrides:
1482
- * @param {string} [config.shape='rect'] - 'rect', 'circle', 'ellipse', 'polygon', ...
1483
- * @param {Object|Array} [config.points] - Required for polygon: [{x, y}, ...] or [[x, y], ...]
1484
- * @param {number|function} [config.width/height/rx/ry/radius] - Can be number or function(canvas, options)
1485
- * @param {number|string|function} [config.left/top] - Absolute, %, or function
1486
- * @param {number|string} [config.angle] - Rotation angle (degree)
1487
- * @param {string} [config.color] - Fill color in CSS color format (default 'rgba(0,0,0,0.5)')
1488
- * @param {number} [config.alpha] - Opacity, from 0 to 1 (default 0.5)
1489
- * @param {boolean} [config.selectable=true]
1490
- * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
1491
- * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
1492
- * @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
1493
- * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
1798
+ *
1799
+ * Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
1800
+ * after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
1801
+ * so controls and hit testing stay in sync with Fabric 5.x behavior.
1802
+ *
1803
+ * @param {Object} [config={}] - Optional mask configuration overrides.
1804
+ * @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
1805
+ * @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
1806
+ * @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
1807
+ * @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
1808
+ * @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
1809
+ * @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
1810
+ * @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
1811
+ * @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
1812
+ * @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
1813
+ * @param {number} [config.angle=0] - Rotation angle in degrees.
1814
+ * @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
1815
+ * @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
1816
+ * @param {boolean} [config.selectable=true] - Whether the mask can be selected.
1817
+ * @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
1818
+ * @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
1819
+ * @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
1820
+ * @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
1821
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1494
1822
  * @public
1495
1823
  */
1496
1824
  createMask(config = {}) {
1497
1825
  if (!this.canvas) return null;
1498
1826
  const shapeType = config.shape || 'rect';
1499
- // Default config
1827
+ // Normalize mask defaults before applying caller-provided overrides.
1500
1828
  const maskConfig = {
1501
1829
  shape: shapeType,
1502
1830
  width: this.options.defaultMaskWidth,
@@ -1544,6 +1872,8 @@ function ensureFabric() {
1544
1872
 
1545
1873
  maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1546
1874
  maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1875
+ maskConfig.left = left;
1876
+ maskConfig.top = top;
1547
1877
 
1548
1878
  let mask;
1549
1879
  if (typeof maskConfig.fabricGenerator === 'function') {
@@ -1573,9 +1903,11 @@ function ensureFabric() {
1573
1903
  break;
1574
1904
  case 'polygon': {
1575
1905
  let polygonPoints = maskConfig.points || [];
1576
- if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === 'object') {
1577
- // Ensure numeric {x,y} objects for fabric.Polygon
1578
- polygonPoints = polygonPoints.map(point => ({ x: Number(point.x), y: Number(point.y) }));
1906
+ if (Array.isArray(polygonPoints) && polygonPoints.length) {
1907
+ // Ensure numeric {x,y} objects for fabric.Polygon.
1908
+ polygonPoints = polygonPoints.map(point => Array.isArray(point)
1909
+ ? { x: Number(point[0]), y: Number(point[1]) }
1910
+ : { x: Number(point.x), y: Number(point.y) });
1579
1911
  }
1580
1912
  mask = new fabric.Polygon(polygonPoints, {
1581
1913
  left, top,
@@ -1595,7 +1927,7 @@ function ensureFabric() {
1595
1927
  fill: maskConfig.color,
1596
1928
  opacity: maskConfig.alpha,
1597
1929
  angle: maskConfig.angle,
1598
- rx: maskConfig.rx, // Rounded Corners
1930
+ rx: maskConfig.rx,
1599
1931
  ry: maskConfig.ry,
1600
1932
  ...maskConfig.styles
1601
1933
  });
@@ -1614,6 +1946,7 @@ function ensureFabric() {
1614
1946
  transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
1615
1947
  stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
1616
1948
  strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
1949
+ opacity: hasStyle('opacity') ? styles.opacity : maskConfig.alpha,
1617
1950
  strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
1618
1951
  };
1619
1952
  if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
@@ -1621,14 +1954,14 @@ function ensureFabric() {
1621
1954
  mask.setCoords();
1622
1955
 
1623
1956
  mask.set({
1624
- originalAlpha: maskConfig.alpha,
1957
+ originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
1625
1958
  originalStroke: mask.stroke || '#ccc',
1626
1959
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1627
1960
  });
1628
1961
  this._rebindMaskEvents(mask);
1629
1962
  this._expandCanvasToFitObject(mask);
1630
1963
 
1631
- // Remember initial for next one
1964
+ // Store placement values so the next mask can be positioned beside this one.
1632
1965
  this._lastMaskInitialLeft = left;
1633
1966
  this._lastMaskInitialTop = top;
1634
1967
  this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
@@ -1654,7 +1987,11 @@ function ensureFabric() {
1654
1987
  }
1655
1988
 
1656
1989
  /**
1657
- * @deprecated Use createMask() instead.
1990
+ * Backward-compatible alias for {@link ImageEditor#createMask}.
1991
+ *
1992
+ * @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
1993
+ * @param {Object} [config={}] - Mask configuration passed to createMask().
1994
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1658
1995
  */
1659
1996
  addMask(config = {}) {
1660
1997
  return this.createMask(config);
@@ -1727,6 +2064,24 @@ function ensureFabric() {
1727
2064
  }
1728
2065
  }
1729
2066
 
2067
+ /**
2068
+ * Returns a stable zero-based creation index for label callbacks.
2069
+ *
2070
+ * Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
2071
+ * lifetime of a mask.
2072
+ *
2073
+ * @param {fabric.Object} mask - Mask object.
2074
+ * @returns {number} Stable zero-based creation index.
2075
+ * @private
2076
+ */
2077
+ _getMaskCreationIndex(mask) {
2078
+ const maskId = Number(mask && mask.maskId);
2079
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
2080
+
2081
+ const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
2082
+ return Math.max(0, masks.indexOf(mask));
2083
+ }
2084
+
1730
2085
  /**
1731
2086
  * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1732
2087
  * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
@@ -1757,9 +2112,7 @@ function ensureFabric() {
1757
2112
  };
1758
2113
  if (this.options.label) {
1759
2114
  if (typeof this.options.label.getText === 'function') {
1760
- const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
1761
- const maskIndex = Math.max(0, masks.indexOf(mask));
1762
- labelText = this.options.label.getText(mask, maskIndex);
2115
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
1763
2116
  }
1764
2117
  // Merge external styles
1765
2118
  if (this.options.label.textOptions) {
@@ -1924,10 +2277,14 @@ function ensureFabric() {
1924
2277
  }
1925
2278
 
1926
2279
  /**
1927
- * Merges current masks into the image: exports a masked/cropped image, removes all masks, and re-imports the merged image.
1928
- * Will not run if no original image or no masks exist.
2280
+ * Flattens the current masks into the base image and reloads the flattened image.
2281
+ *
2282
+ * This removes editable mask objects after export and records the operation as one undoable history transition.
2283
+ * It does nothing when no base image or no masks exist.
2284
+ *
1929
2285
  * @async
1930
- * @returns {Promise<void>} Resolves when merge and load are complete.
2286
+ * @returns {Promise<void>} Resolves when the flattened image has been loaded.
2287
+ * @public
1931
2288
  */
1932
2289
  async mergeMasks() {
1933
2290
  if (!this.originalImage) return;
@@ -1941,53 +2298,62 @@ function ensureFabric() {
1941
2298
  const beforeJson = this._serializeCanvasState();
1942
2299
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1943
2300
  this.removeAllMasks({ saveHistory: false });
1944
- await this.loadImage(merged);
2301
+ await this.loadImage(merged, { preserveScroll: true });
1945
2302
  const afterJson = this._serializeCanvasState();
1946
2303
  this._pushStateTransition(beforeJson, afterJson);
1947
- } catch (err) {
1948
- this._reportError('merge error', err);
2304
+ } catch (error) {
2305
+ this._reportError('merge error', error);
1949
2306
  }
1950
2307
  }
1951
2308
 
1952
2309
  /**
1953
- * @deprecated Use mergeMasks() instead.
2310
+ * Backward-compatible alias for {@link ImageEditor#mergeMasks}.
2311
+ *
2312
+ * @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
2313
+ * @returns {Promise<void>} Resolves when mask flattening is complete.
1954
2314
  */
1955
2315
  async merge() {
1956
2316
  return this.mergeMasks();
1957
2317
  }
1958
2318
 
1959
2319
  /**
1960
- * Triggers a JPEG image download of the current canvas (image plus masks if configured).
2320
+ * Triggers a JPEG image download of the current canvas.
2321
+ *
1961
2322
  * The image area and multiplier are controlled by options.
1962
2323
  * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
2324
+ * @returns {void}
2325
+ * @public
1963
2326
  */
1964
2327
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1965
2328
  if (!this.originalImage) return;
1966
2329
  const exportImageArea = this.options.exportImageAreaByDefault;
1967
2330
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1968
- .then(base64 => {
2331
+ .then(imageBase64 => {
1969
2332
  const link = document.createElement('a');
1970
2333
  link.download = fileName;
1971
- link.href = base64;
2334
+ link.href = imageBase64;
1972
2335
  document.body.appendChild(link);
1973
2336
  link.click();
1974
2337
  document.body.removeChild(link);
1975
2338
  })
1976
- .catch(err => this._reportError('download error', err));
2339
+ .catch(error => this._reportError('download error', error));
1977
2340
  }
1978
2341
 
1979
2342
  /**
1980
- * Exports the image as a Base64-encoded image data URL.
1981
- * Can export either the original, or the current view including masks (clipped/cropped).
1982
- * Will restore masks' state after temporary modifications for export.
2343
+ * Exports the current image as a Base64-encoded data URL.
2344
+ *
2345
+ * When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
2346
+ * temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
2347
+ *
1983
2348
  * @async
1984
2349
  * @param {Object} [options={}] - Export options.
1985
2350
  * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1986
2351
  * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
1987
2352
  * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
1988
2353
  * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
1989
- * @returns {Promise<string>} Promise resolving to an image data URL.
2354
+ * @returns {Promise<string>} Resolves with an image data URL.
1990
2355
  * @throws {Error} If there is no image loaded.
2356
+ * @public
1991
2357
  */
1992
2358
  async exportImageBase64(options = {}) {
1993
2359
  if (!this.originalImage) throw new Error('No image loaded');
@@ -2007,12 +2373,9 @@ function ensureFabric() {
2007
2373
 
2008
2374
  this.originalImage.setCoords();
2009
2375
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2010
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2376
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2011
2377
  return await this._exportCanvasRegionToDataURL({
2012
- sx,
2013
- sy,
2014
- sw,
2015
- sh,
2378
+ ...exportRegion,
2016
2379
  multiplier,
2017
2380
  quality,
2018
2381
  format
@@ -2025,7 +2388,7 @@ function ensureFabric() {
2025
2388
  }
2026
2389
  }
2027
2390
 
2028
- // Export current scaled image area (masks clipped)
2391
+ // Render masks as export shapes without mutating their editable styles.
2029
2392
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2030
2393
  const maskStyleBackups = masks.map(mask => ({
2031
2394
  object: mask,
@@ -2039,29 +2402,26 @@ function ensureFabric() {
2039
2402
 
2040
2403
  let finalBase64;
2041
2404
  try {
2042
- // Remove labels, deselect
2405
+ // Labels are UI overlays and should not be part of the flattened export.
2043
2406
  masks.forEach(mask => this._removeLabelForMask(mask));
2044
2407
  this.canvas.discardActiveObject();
2045
2408
  this.canvas.renderAll();
2046
2409
 
2047
- // Set masks to opaque black no border
2410
+ // The export treats masks as opaque shapes with no editable border.
2048
2411
  masks.forEach(mask => {
2049
2412
  mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
2050
2413
  mask.setCoords();
2051
2414
  });
2052
2415
  this.canvas.renderAll();
2053
2416
 
2054
- // Compute integer bounding box for image
2417
+ // Compute an integer canvas region for the base image.
2055
2418
  this.originalImage.setCoords();
2056
2419
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2057
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2420
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2058
2421
 
2059
2422
  // Crop precisely in offscreen canvas
2060
2423
  finalBase64 = await this._exportCanvasRegionToDataURL({
2061
- sx,
2062
- sy,
2063
- sw,
2064
- sh,
2424
+ ...exportRegion,
2065
2425
  multiplier,
2066
2426
  quality,
2067
2427
  format
@@ -2088,15 +2448,21 @@ function ensureFabric() {
2088
2448
  }
2089
2449
 
2090
2450
  /**
2091
- * @deprecated Use exportImageBase64() instead.
2451
+ * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
2452
+ *
2453
+ * @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
2454
+ * @param {Object} [options={}] - Export options passed to exportImageBase64().
2455
+ * @returns {Promise<string>} Resolves with an image data URL.
2092
2456
  */
2093
2457
  async getImageBase64(options = {}) {
2094
2458
  return this.exportImageBase64(options);
2095
2459
  }
2096
2460
 
2097
2461
  /**
2098
- * Exports the current canvas (with or without masks) as a File object.
2099
- * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
2462
+ * Exports the current image as a File object.
2463
+ *
2464
+ * The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
2465
+ * Supported output formats are JPEG, PNG, and WebP.
2100
2466
  *
2101
2467
  * @async
2102
2468
  * @param {Object} [options={}] - Export options.
@@ -2122,17 +2488,17 @@ function ensureFabric() {
2122
2488
 
2123
2489
  const safeFileType = this._normalizeImageFormat(fileType);
2124
2490
 
2125
- // Get Base64
2126
- let base64;
2491
+ // Generate the data URL in the requested export mode.
2492
+ let imageBase64;
2127
2493
  if (mergeMask) {
2128
- base64 = await this.exportImageBase64({
2494
+ imageBase64 = await this.exportImageBase64({
2129
2495
  exportImageArea: true,
2130
2496
  multiplier,
2131
2497
  quality,
2132
2498
  fileType: safeFileType
2133
2499
  });
2134
2500
  } else {
2135
- base64 = await this.exportImageBase64({
2501
+ imageBase64 = await this.exportImageBase64({
2136
2502
  exportImageArea: false,
2137
2503
  multiplier,
2138
2504
  quality,
@@ -2141,9 +2507,9 @@ function ensureFabric() {
2141
2507
  }
2142
2508
 
2143
2509
  // Convert to the required image format
2144
- let imageDataUrl = base64;
2510
+ let imageDataUrl = imageBase64;
2145
2511
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
2146
- // Redraw if not required format
2512
+ // Redraw the exported data URL when the browser returned a different image format.
2147
2513
  imageDataUrl = await new Promise((resolve, reject) => {
2148
2514
  const imageElement = new window.Image();
2149
2515
  imageElement.crossOrigin = "Anonymous";
@@ -2159,11 +2525,11 @@ function ensureFabric() {
2159
2525
  } catch (error) { reject(error); }
2160
2526
  };
2161
2527
  imageElement.onerror = reject;
2162
- imageElement.src = base64;
2528
+ imageElement.src = imageBase64;
2163
2529
  });
2164
2530
  }
2165
2531
 
2166
- // Convert DataURL to Blob and then to File
2532
+ // Convert the final data URL to a File with the requested MIME type.
2167
2533
  const binaryString = atob(imageDataUrl.split(',')[1]);
2168
2534
  const mime = `image/${safeFileType}`;
2169
2535
  let byteIndex = binaryString.length;
@@ -2237,7 +2603,12 @@ function ensureFabric() {
2237
2603
  }
2238
2604
 
2239
2605
  /**
2240
- * Enter crop mode: create a resizable/movable selection rect on top of the image.
2606
+ * Enters crop mode by creating a resizable crop rectangle above the base image.
2607
+ *
2608
+ * Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
2609
+ * cropping when `crop.hideMasksDuringCrop` is enabled.
2610
+ *
2611
+ * @returns {void}
2241
2612
  * @public
2242
2613
  */
2243
2614
  enterCropMode() {
@@ -2245,24 +2616,30 @@ function ensureFabric() {
2245
2616
  if (!this.isImageLoaded()) return;
2246
2617
  this._cropMode = true;
2247
2618
 
2248
- // Disable canvas group selection to avoid accidental group selection while cropping
2619
+ // Disable group selection so only the crop rectangle can be manipulated.
2249
2620
  this._prevSelectionSetting = this.canvas.selection;
2250
2621
  this.canvas.selection = false;
2251
2622
 
2252
- // Make sure no active object
2623
+ // Clear the current selection before activating the crop rectangle.
2253
2624
  this.canvas.discardActiveObject();
2254
2625
 
2255
- // Create initial crop rect centered on the image bounding box
2626
+ // Create the initial crop rectangle inside the image bounds.
2256
2627
  this.originalImage.setCoords();
2257
2628
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2258
- // Provide small inset so user can see a margin
2629
+ // Use a small inset so the user can see the crop boundary.
2259
2630
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
2260
2631
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
2261
2632
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
2262
- const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imageBounds.width - padding * 2));
2263
- const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imageBounds.height - padding * 2));
2264
-
2265
- // Visual style: translucent fill + dashed stroke
2633
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2634
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
2635
+ const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
2636
+ const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
2637
+ const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
2638
+ const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
2639
+ const width = minCropWidth;
2640
+ const height = minCropHeight;
2641
+
2642
+ // Visual style for the temporary crop rectangle.
2266
2643
  const cropRect = new fabric.Rect({
2267
2644
  left, top,
2268
2645
  width, height,
@@ -2277,7 +2654,8 @@ function ensureFabric() {
2277
2654
  cornerSize: 8,
2278
2655
  objectCaching: false,
2279
2656
  originX: 'left',
2280
- originY: 'top'
2657
+ originY: 'top',
2658
+ lockScalingFlip: true
2281
2659
  });
2282
2660
 
2283
2661
  // Ensure the crop rect is above everything
@@ -2286,11 +2664,10 @@ function ensureFabric() {
2286
2664
  this.canvas.bringToFront(cropRect);
2287
2665
  this.canvas.setActiveObject(cropRect);
2288
2666
 
2289
- // Keep reference
2667
+ // Store the crop rectangle so apply/cancel can clean it up.
2290
2668
  this._cropRect = cropRect;
2291
2669
 
2292
- // While in crop mode: we want only the cropRect to be interactive
2293
- // but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
2670
+ // Keep only the crop rectangle interactive while preserving each object's previous state.
2294
2671
  this._cropPrevEvented = [];
2295
2672
  const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
2296
2673
  this.canvas.getObjects().forEach(object => {
@@ -2307,13 +2684,23 @@ function ensureFabric() {
2307
2684
  }
2308
2685
  });
2309
2686
 
2310
- // When the crop rect changes, re-render
2311
- const handleCropRectModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (error) { void error; } };
2687
+ // Keep Fabric controls and configured size limits in sync as the crop rectangle changes.
2688
+ const handleCropRectModified = () => {
2689
+ try {
2690
+ const cropWidth = Math.max(1, Number(cropRect.width) || 1);
2691
+ const cropHeight = Math.max(1, Number(cropRect.height) || 1);
2692
+ const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
2693
+ const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2694
+ cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2695
+ cropRect.setCoords();
2696
+ this.canvas.requestRenderAll();
2697
+ } catch (error) { void error; }
2698
+ };
2312
2699
  cropRect.on('modified', handleCropRectModified);
2313
2700
  cropRect.on('moving', handleCropRectModified);
2314
2701
  cropRect.on('scaling', handleCropRectModified);
2315
2702
 
2316
- // Keep handlers to remove later
2703
+ // Store handlers so cancel/apply/dispose can unbind them.
2317
2704
  this._cropHandlers.push({
2318
2705
  target: cropRect,
2319
2706
  handlers: [
@@ -2328,7 +2715,9 @@ function ensureFabric() {
2328
2715
  }
2329
2716
 
2330
2717
  /**
2331
- * Cancel crop mode and remove the temporary selection rect.
2718
+ * Cancels crop mode and removes the temporary crop rectangle.
2719
+ *
2720
+ * @returns {void}
2332
2721
  * @public
2333
2722
  */
2334
2723
  cancelCrop() {
@@ -2336,7 +2725,7 @@ function ensureFabric() {
2336
2725
  this._removeCropRect();
2337
2726
  this._restoreCropObjectState();
2338
2727
  this._cropMode = false;
2339
- // restore selection setting
2728
+ // Restore the canvas selection setting that was active before crop mode.
2340
2729
  this.canvas.selection = !!this._prevSelectionSetting;
2341
2730
  this._prevSelectionSetting = undefined;
2342
2731
 
@@ -2346,18 +2735,24 @@ function ensureFabric() {
2346
2735
  }
2347
2736
 
2348
2737
  /**
2349
- * Apply the current crop rectangle.
2350
- * remove all masks and export canvas snapshot and crop via offscreen canvas
2738
+ * Applies the current crop rectangle to the base image.
2739
+ *
2740
+ * Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
2741
+ * region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
2742
+ * single undoable history transition.
2743
+ *
2744
+ * @async
2745
+ * @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
2351
2746
  * @public
2352
2747
  */
2353
2748
  async applyCrop() {
2354
2749
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2355
2750
 
2356
- // Ensure crop rect coords are fresh
2751
+ // Fabric does not update control coordinates automatically after programmatic transforms.
2357
2752
  this._cropRect.setCoords();
2358
2753
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2359
2754
 
2360
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
2755
+ const cropRegion = this._getClampedCanvasRegion(rectBounds);
2361
2756
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2362
2757
 
2363
2758
  this._restoreCropObjectState();
@@ -2380,16 +2775,16 @@ function ensureFabric() {
2380
2775
  mask.setCoords();
2381
2776
  const maskBounds = mask.getBoundingRect(true, true);
2382
2777
  const intersectsCrop =
2383
- maskBounds.left < sx + sw &&
2384
- maskBounds.left + maskBounds.width > sx &&
2385
- maskBounds.top < sy + sh &&
2386
- maskBounds.top + maskBounds.height > sy;
2778
+ maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
2779
+ maskBounds.left + maskBounds.width > cropRegion.sourceX &&
2780
+ maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
2781
+ maskBounds.top + maskBounds.height > cropRegion.sourceY;
2387
2782
  this._removeLabelForMask(mask);
2388
2783
  this.canvas.remove(mask);
2389
2784
  if (shouldPreserveMasks && intersectsCrop) {
2390
2785
  mask.set({
2391
- left: (mask.left || 0) - sx,
2392
- top: (mask.top || 0) - sy,
2786
+ left: (mask.left || 0) - cropRegion.sourceX,
2787
+ top: (mask.top || 0) - cropRegion.sourceY,
2393
2788
  visible: true
2394
2789
  });
2395
2790
  mask.setCoords();
@@ -2409,19 +2804,16 @@ function ensureFabric() {
2409
2804
 
2410
2805
  this._removeCropRect();
2411
2806
 
2412
- // End crop mode
2807
+ // End crop mode before loading the cropped image.
2413
2808
  this._cropMode = false;
2414
2809
  this.canvas.selection = !!this._prevSelectionSetting;
2415
2810
  this._prevSelectionSetting = undefined;
2416
2811
 
2417
- // Export full canvas and crop on offscreen canvas
2812
+ // Export the crop region from the current canvas.
2418
2813
  let croppedBase64;
2419
2814
  try {
2420
2815
  croppedBase64 = await this._exportCanvasRegionToDataURL({
2421
- sx,
2422
- sy,
2423
- sw,
2424
- sh,
2816
+ ...cropRegion,
2425
2817
  multiplier: 1,
2426
2818
  quality: this._normalizeQuality(this.options.downsampleQuality),
2427
2819
  format: 'jpeg'
@@ -2431,7 +2823,7 @@ function ensureFabric() {
2431
2823
  return;
2432
2824
  }
2433
2825
 
2434
- // Load the cropped image as the new base image
2826
+ // Load the cropped image as the new base image.
2435
2827
  try {
2436
2828
  await this.loadImage(croppedBase64);
2437
2829
  if (preservedMasks.length) {
@@ -2445,27 +2837,27 @@ function ensureFabric() {
2445
2837
  this._updateMaskList();
2446
2838
  this.canvas.renderAll();
2447
2839
  }
2448
- } catch (e) {
2449
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', e);
2840
+ } catch (error) {
2841
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
2450
2842
  return;
2451
2843
  }
2452
2844
 
2453
- // Create "after" snapshot (also exclude crop rect if any) and push history command
2845
+ // Create an after snapshot and push one history command for the crop operation.
2454
2846
  let afterJson = null;
2455
2847
  try {
2456
2848
  afterJson = this._serializeCanvasState();
2457
- } catch (e) {
2458
- this._reportWarning('applyCrop: failed to serialize after state', e);
2849
+ } catch (error) {
2850
+ this._reportWarning('applyCrop: failed to serialize after state', error);
2459
2851
  afterJson = null;
2460
2852
  }
2461
2853
 
2462
2854
  try {
2463
2855
  this._pushStateTransition(beforeJson, afterJson);
2464
- } catch (e) {
2465
- this._reportWarning('applyCrop: failed to push history command', e);
2856
+ } catch (error) {
2857
+ this._reportWarning('applyCrop: failed to push history command', error);
2466
2858
  }
2467
2859
 
2468
- // Final UI update
2860
+ // Refresh UI state after crop completion.
2469
2861
  this._updateUI();
2470
2862
  this.canvas.renderAll();
2471
2863
  }
@@ -2500,7 +2892,7 @@ function ensureFabric() {
2500
2892
  const isInCropMode = !!this._cropMode;
2501
2893
 
2502
2894
  if (isInCropMode) {
2503
- // iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
2895
+ // Disable all controls except the crop action buttons while crop mode is active.
2504
2896
  for (const key of Object.keys(this.elements || {})) {
2505
2897
  const element = document.getElementById(this.elements[key]);
2506
2898
  if (!element) continue;
@@ -2563,7 +2955,7 @@ function ensureFabric() {
2563
2955
  }
2564
2956
 
2565
2957
  /**
2566
- * Automatically display and hide placeholders and containers based on the current image content
2958
+ * Updates placeholder and canvas container visibility based on whether an image is loaded.
2567
2959
  * @private
2568
2960
  */
2569
2961
  _updatePlaceholderStatus() {
@@ -2572,12 +2964,13 @@ function ensureFabric() {
2572
2964
  }
2573
2965
 
2574
2966
  /**
2575
- * Controls the display/hiding of the Placeholder and Canvas container.
2576
- * @param {boolean} show - true displays the placeholder, false displays the canvas container
2967
+ * Shows or hides the placeholder and canvas container.
2968
+ *
2969
+ * @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
2577
2970
  * @private
2578
2971
  */
2579
2972
  _setPlaceholderVisible(show) {
2580
- if (!this.placeholderElement) return;
2973
+ if (!this.placeholderElement || !this.containerElement) return;
2581
2974
  if (show) {
2582
2975
  this.placeholderElement.classList.remove('d-none');
2583
2976
  this.placeholderElement.classList.add('d-flex');
@@ -2608,16 +3001,16 @@ function ensureFabric() {
2608
3001
  } catch (error) { void error; }
2609
3002
 
2610
3003
  if (this._cropRect) {
2611
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
3004
+ try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
2612
3005
  this._cropRect = null;
2613
3006
  }
2614
3007
 
2615
3008
  if (this.containerElement && this._containerOriginalOverflow !== undefined) {
2616
- try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (e) { void e; }
3009
+ try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
2617
3010
  }
2618
3011
 
2619
3012
  if (this.canvas) {
2620
- try { this.canvas.dispose(); } catch (e) { void e; }
3013
+ try { this.canvas.dispose(); } catch (error) { void error; }
2621
3014
  this.canvas = null;
2622
3015
  this.canvasElement = null;
2623
3016
  this.isImageLoadedToCanvas = false;
@@ -2627,113 +3020,160 @@ function ensureFabric() {
2627
3020
  }
2628
3021
 
2629
3022
  /**
2630
- * A simple FIFO queue that guarantees animations are executed sequentially.
2631
- * @class AnimationQueue
3023
+ * @callback AnimationTaskCallback
3024
+ * @returns {unknown} Animation result or awaitable animation result.
3025
+ */
3026
+
3027
+ /**
3028
+ * @callback PromiseResolveCallback
3029
+ * @param {unknown} value - Promise resolution value.
3030
+ * @returns {void}
3031
+ */
3032
+
3033
+ /**
3034
+ * @callback PromiseRejectCallback
3035
+ * @param {unknown} reason - Promise rejection reason.
3036
+ * @returns {void}
3037
+ */
3038
+
3039
+ /**
3040
+ * @typedef {Object} QueuedAnimationTask
3041
+ * @property {AnimationTaskCallback} animationFn - Queued animation function.
3042
+ * @property {PromiseResolveCallback} resolve - Promise resolver for the queued animation.
3043
+ * @property {PromiseRejectCallback} reject - Promise rejecter for the queued animation.
3044
+ */
3045
+
3046
+ /**
3047
+ * @callback HistoryTaskCallback
3048
+ * @returns {void|Promise<void>} Result of a history operation.
3049
+ */
3050
+
3051
+ /**
3052
+ * FIFO queue that serializes transform animations so Fabric state changes do not overlap.
3053
+ *
3054
+ * @private
2632
3055
  */
2633
3056
  class AnimationQueue {
2634
3057
  /**
2635
- * Creates a new AnimationQueue.
2636
- *
2637
- * @constructor
3058
+ * Creates an empty animation queue.
2638
3059
  */
2639
3060
  constructor() {
2640
3061
  /**
2641
- * Internal queue holding animation descriptors.
2642
- * @type {Array<{fn: Function, resolve: Function, reject: Function}>}
3062
+ * Pending animation descriptors.
3063
+ * @type {Array<QueuedAnimationTask>}
2643
3064
  */
2644
- this.queue = [];
3065
+ this.animationTasks = [];
2645
3066
  /**
2646
- * Flag indicating whether an animation is currently running.
3067
+ * Whether an animation task is currently running.
2647
3068
  * @type {boolean}
2648
3069
  */
2649
- this.running = false;
3070
+ this.isRunning = false;
2650
3071
  }
2651
3072
 
2652
3073
  /**
2653
3074
  * Adds an animation function to the queue.
2654
3075
  *
2655
- * @param {Function} animationFn A function that returns a Promise or any await-able.
2656
- * @returns {Promise<*>} A Promise that resolves/rejects with the animation result.
3076
+ * @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
3077
+ * @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
2657
3078
  */
2658
3079
  async add(animationFn) {
2659
3080
  return new Promise((resolve, reject) => {
2660
- // Push the animation into the queue.
2661
- this.queue.push({ fn: animationFn, resolve, reject });
2662
- // Start processing if it's not already running.
2663
- if (!this.running) {
2664
- this.processQueue();
3081
+ this.animationTasks.push({ animationFn, resolve, reject });
3082
+ if (!this.isRunning) {
3083
+ this._drainQueue();
2665
3084
  }
2666
3085
  });
2667
3086
  }
2668
3087
 
2669
3088
  /**
2670
- * Internal helper that processes the animation queue sequentially until it is empty.
3089
+ * Runs queued animation tasks sequentially until the queue is empty.
2671
3090
  *
2672
3091
  * @private
2673
3092
  * @returns {Promise<void>}
2674
3093
  */
2675
- async processQueue() {
2676
- if (this.queue.length === 0) {
2677
- this.running = false;
3094
+ async _drainQueue() {
3095
+ if (this.animationTasks.length === 0) {
3096
+ this.isRunning = false;
2678
3097
  return;
2679
3098
  }
2680
3099
 
2681
- this.running = true;
2682
- const { fn, resolve, reject } = this.queue.shift();
3100
+ this.isRunning = true;
3101
+ const { animationFn, resolve, reject } = this.animationTasks.shift();
2683
3102
 
2684
3103
  try {
2685
- const result = await fn();
3104
+ const result = await animationFn();
2686
3105
  resolve(result);
2687
3106
  } catch (error) {
2688
3107
  reject(error);
2689
3108
  }
2690
3109
 
2691
- this.processQueue();
3110
+ await this._drainQueue();
2692
3111
  }
2693
3112
  }
2694
3113
 
2695
3114
  /**
2696
- * Command object encapsulating an executable action and its corresponding undo operation.
2697
- * @class Command
3115
+ * Undoable command with paired execute and undo operations.
3116
+ *
3117
+ * @private
2698
3118
  */
2699
3119
  class Command {
2700
3120
  /**
2701
- * @param {Function} execute The function that performs the action.
2702
- * @param {Function} undo The function that reverts the action.
3121
+ * @param {HistoryTaskCallback} execute - Function that performs the action.
3122
+ * @param {HistoryTaskCallback} undo - Function that reverts the action.
2703
3123
  */
2704
3124
  constructor(execute, undo) {
2705
3125
  /**
2706
3126
  * Executes the command.
2707
- * @type {Function}
3127
+ * @type {HistoryTaskCallback}
2708
3128
  */
2709
3129
  this.execute = execute;
2710
3130
  /**
2711
3131
  * Undoes the command.
2712
- * @type {Function}
3132
+ * @type {HistoryTaskCallback}
2713
3133
  */
2714
3134
  this.undo = undo;
2715
3135
  }
2716
3136
  }
2717
3137
 
2718
3138
  /**
2719
- * Manages a history of Command objects enabling undo/redo functionality.
2720
- * @class HistoryManager
3139
+ * Manages undo/redo history and serializes asynchronous history operations.
3140
+ *
3141
+ * @private
2721
3142
  */
2722
3143
  class HistoryManager {
2723
3144
  /**
2724
- * @param {number} [maxSize=50] Maximum number of commands to keep in history.
3145
+ * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
2725
3146
  */
2726
3147
  constructor(maxSize = 50) {
3148
+ /** @type {Array<Command>} */
2727
3149
  this.history = [];
3150
+ /** @type {number} */
2728
3151
  this.currentIndex = -1;
3152
+ /** @type {number} */
2729
3153
  this.maxSize = maxSize;
3154
+ /** @type {Promise<void>} */
2730
3155
  this.pending = Promise.resolve();
2731
3156
  }
2732
3157
 
3158
+ /**
3159
+ * Queues a history task after the previously queued undo/redo task completes.
3160
+ *
3161
+ * @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
3162
+ * @returns {Promise<void>} Resolves or rejects with the queued task result.
3163
+ * @private
3164
+ */
2733
3165
  enqueue(task) {
2734
- const run = this.pending.then(task, task);
2735
- this.pending = run.catch(() => {});
2736
- return run;
3166
+ const nextTask = this.pending.then(task, task);
3167
+ let pendingAfterTask;
3168
+ const resetPending = () => {
3169
+ if (this.pending === pendingAfterTask) {
3170
+ this.pending = Promise.resolve();
3171
+ }
3172
+ };
3173
+
3174
+ pendingAfterTask = nextTask.then(resetPending, resetPending);
3175
+ this.pending = pendingAfterTask;
3176
+ return nextTask;
2737
3177
  }
2738
3178
 
2739
3179
  /**
@@ -2744,7 +3184,6 @@ function ensureFabric() {
2744
3184
  * @returns {void}
2745
3185
  */
2746
3186
  execute(command) {
2747
- // Perform the command.
2748
3187
  command.execute();
2749
3188
  this.push(command);
2750
3189
  }
@@ -2757,17 +3196,15 @@ function ensureFabric() {
2757
3196
  * @returns {void}
2758
3197
  */
2759
3198
  push(command) {
2760
- // Remove any commands that are ahead of the current index.
3199
+ // Discard redo commands when a new branch is created.
2761
3200
  if (this.currentIndex < this.history.length - 1) {
2762
3201
  this.history = this.history.slice(0, this.currentIndex + 1);
2763
3202
  }
2764
3203
 
2765
- // Add the new command.
2766
3204
  this.history.push(command);
2767
3205
 
2768
- // Maintain the max size of the buffer.
2769
3206
  if (this.history.length > this.maxSize) {
2770
- this.history.shift(); // Remove the oldest command.
3207
+ this.history.shift();
2771
3208
  } else {
2772
3209
  this.currentIndex++;
2773
3210
  }
@@ -2794,7 +3231,7 @@ function ensureFabric() {
2794
3231
  /**
2795
3232
  * Undoes the last executed command if possible.
2796
3233
  *
2797
- * @returns {void}
3234
+ * @returns {Promise<void>} Resolves after the undo task completes.
2798
3235
  */
2799
3236
  undo() {
2800
3237
  return this.enqueue(async () => {
@@ -2809,7 +3246,7 @@ function ensureFabric() {
2809
3246
  /**
2810
3247
  * Redoes the next command in history if possible.
2811
3248
  *
2812
- * @returns {void}
3249
+ * @returns {Promise<void>} Resolves after the redo task completes.
2813
3250
  */
2814
3251
  redo() {
2815
3252
  return this.enqueue(async () => {