@bensitu/image-editor 1.2.2 → 1.3.1

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.1
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
516
623
  */
517
- async loadImage(imageBase64) {
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
645
+ */
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 = () => {
660
- imageElement.onload = null;
661
- imageElement.onerror = null;
662
- resolve(imageElement);
663
- };
664
- imageElement.onerror = (error) => {
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);
665
799
  imageElement.onload = null;
666
800
  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
  }
@@ -727,25 +868,36 @@ function ensureFabric() {
727
868
  };
728
869
  }
729
870
 
871
+ let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
872
+ let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
873
+
730
874
  if (this._hasFixedContainerScrollbars()) {
731
- return {
732
- width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
733
- height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
734
- };
875
+ return { width, height };
735
876
  }
736
877
 
737
- const previousOverflow = this.containerElement.style.overflow;
738
- this.containerElement.style.overflow = 'hidden';
878
+ const overflow = this._getContainerOverflowValues();
879
+ const canScrollX = overflow.x.some(value => value === 'auto' || value === 'scroll');
880
+ const canScrollY = overflow.y.some(value => value === 'auto' || value === 'scroll');
881
+ const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
882
+ const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
739
883
 
740
- const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
741
- const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
884
+ if (hasHorizontalScrollbar || hasVerticalScrollbar) {
885
+ const scrollbar = this._getScrollbarSize();
886
+ if (hasVerticalScrollbar) width += scrollbar.width;
887
+ if (hasHorizontalScrollbar) height += scrollbar.height;
888
+ }
742
889
 
743
- this.containerElement.style.overflow = previousOverflow;
744
890
  return { width, height };
745
891
  }
746
892
 
747
- _hasFixedContainerScrollbars() {
748
- if (!this.containerElement) return false;
893
+ /**
894
+ * Reads inline and computed overflow values for both scroll axes.
895
+ *
896
+ * @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
897
+ * @private
898
+ */
899
+ _getContainerOverflowValues() {
900
+ if (!this.containerElement) return { x: [], y: [] };
749
901
  const inlineOverflow = this.containerElement.style.overflow;
750
902
  const inlineOverflowX = this.containerElement.style.overflowX;
751
903
  const inlineOverflowY = this.containerElement.style.overflowY;
@@ -760,11 +912,22 @@ function ensureFabric() {
760
912
  computedOverflowY = style.overflowY;
761
913
  }
762
914
 
763
- return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY]
764
- .some(value => value === 'scroll');
915
+ return {
916
+ x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
917
+ y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
918
+ };
919
+ }
920
+
921
+ _hasFixedContainerScrollbars() {
922
+ if (!this.containerElement) return false;
923
+ const overflow = this._getContainerOverflowValues();
924
+ return [...overflow.x, ...overflow.y].some(value => value === 'scroll');
765
925
  }
766
926
 
767
927
  _getScrollbarSize() {
928
+ if (this._scrollbarSizeCache) {
929
+ return { ...this._scrollbarSizeCache };
930
+ }
768
931
  if (typeof document === 'undefined' || !document.createElement || !document.body) {
769
932
  return { width: 0, height: 0 };
770
933
  }
@@ -782,7 +945,8 @@ function ensureFabric() {
782
945
  const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
783
946
  document.body.removeChild(probe);
784
947
 
785
- return { width, height };
948
+ this._scrollbarSizeCache = { width, height };
949
+ return { ...this._scrollbarSizeCache };
786
950
  }
787
951
 
788
952
  _getScrollSafetyMargin() {
@@ -807,8 +971,8 @@ function ensureFabric() {
807
971
  const scrollbar = this._getScrollbarSize();
808
972
  let hasVertical = false;
809
973
  let hasHorizontal = false;
810
- let effectiveWidth = viewport.width;
811
- let effectiveHeight = viewport.height;
974
+ let effectiveWidth;
975
+ let effectiveHeight;
812
976
 
813
977
  for (let i = 0; i < 4; i += 1) {
814
978
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
@@ -859,8 +1023,8 @@ function ensureFabric() {
859
1023
  let scale = 1;
860
1024
  let contentWidth = imageWidth;
861
1025
  let contentHeight = imageHeight;
862
- let effectiveWidth = viewport.width;
863
- let effectiveHeight = viewport.height;
1026
+ let effectiveWidth;
1027
+ let effectiveHeight;
864
1028
 
865
1029
  for (let i = 0; i < 4; i += 1) {
866
1030
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
@@ -921,26 +1085,36 @@ function ensureFabric() {
921
1085
  _withNormalizedMaskStyles(callback) {
922
1086
  if (!this.canvas) return callback();
923
1087
  const masks = this.canvas.getObjects().filter(object => object.maskId);
924
- const maskStyleBackups = masks.map(mask => ({
925
- object: mask,
926
- stroke: mask.stroke,
927
- strokeWidth: mask.strokeWidth,
928
- opacity: mask.opacity
929
- }));
1088
+ const maskStyleBackups = [];
930
1089
 
931
1090
  try {
932
1091
  masks.forEach(mask => {
933
- mask.set(this._getMaskNormalStyle(mask));
1092
+ const normalStyle = this._getMaskNormalStyle(mask);
1093
+ const stylePatch = {};
1094
+ Object.keys(normalStyle).forEach(property => {
1095
+ if (mask[property] !== normalStyle[property]) {
1096
+ stylePatch[property] = normalStyle[property];
1097
+ }
1098
+ });
1099
+ const changedProperties = Object.keys(stylePatch);
1100
+ if (!changedProperties.length) return;
1101
+
1102
+ const backup = { object: mask };
1103
+ changedProperties.forEach(property => {
1104
+ backup[property] = mask[property];
1105
+ });
1106
+ maskStyleBackups.push(backup);
1107
+ mask.set(stylePatch);
934
1108
  });
935
1109
  return callback();
936
1110
  } finally {
937
1111
  maskStyleBackups.forEach(backup => {
938
1112
  try {
939
- backup.object.set({
940
- stroke: backup.stroke,
941
- strokeWidth: backup.strokeWidth,
942
- opacity: backup.opacity
1113
+ const restorePatch = {};
1114
+ Object.keys(backup).forEach(property => {
1115
+ if (property !== 'object') restorePatch[property] = backup[property];
943
1116
  });
1117
+ backup.object.set(restorePatch);
944
1118
  } catch (error) { void error; }
945
1119
  });
946
1120
  }
@@ -964,6 +1138,27 @@ function ensureFabric() {
964
1138
  if (typeof mask.setCoords === 'function') mask.setCoords();
965
1139
  }
966
1140
 
1141
+ /**
1142
+ * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1143
+ *
1144
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1145
+ * @private
1146
+ */
1147
+ _serializeEditorMetadata() {
1148
+ const baseImageScale = Number(this.baseImageScale);
1149
+ const currentScale = Number(this.currentScale);
1150
+ const currentRotation = Number(this.currentRotation);
1151
+ const maskCounter = Number(this.maskCounter);
1152
+
1153
+ return {
1154
+ version: 1,
1155
+ baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1156
+ currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1157
+ currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1158
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1159
+ };
1160
+ }
1161
+
967
1162
  _serializeCanvasState() {
968
1163
  if (!this.canvas) return null;
969
1164
  return this._withNormalizedMaskStyles(() => {
@@ -971,16 +1166,31 @@ function ensureFabric() {
971
1166
  if (Array.isArray(jsonObject.objects)) {
972
1167
  jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
973
1168
  }
1169
+ jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
974
1170
  return JSON.stringify(jsonObject);
975
1171
  });
976
1172
  }
977
1173
 
1174
+ /**
1175
+ * Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
1176
+ *
1177
+ * @param {number} quality - Requested image quality.
1178
+ * @returns {number} A finite quality value between 0 and 1.
1179
+ * @private
1180
+ */
978
1181
  _normalizeQuality(quality) {
979
1182
  const numericQuality = Number(quality);
980
1183
  if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
981
1184
  return Math.max(0, Math.min(1, numericQuality));
982
1185
  }
983
1186
 
1187
+ /**
1188
+ * Normalizes public image format aliases to canvas export format names.
1189
+ *
1190
+ * @param {string} format - Requested image format or MIME type.
1191
+ * @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
1192
+ * @private
1193
+ */
984
1194
  _normalizeImageFormat(format) {
985
1195
  const typeMapping = {
986
1196
  'jpeg': 'jpeg',
@@ -994,6 +1204,15 @@ function ensureFabric() {
994
1204
  return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
995
1205
  }
996
1206
 
1207
+ /**
1208
+ * Converts a bounding rectangle into a canvas-safe integer source region.
1209
+ *
1210
+ * @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
1211
+ * @param {Object} [options={}] - Region rounding options.
1212
+ * @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
1213
+ * @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
1214
+ * @private
1215
+ */
997
1216
  _getClampedCanvasRegion(bounds, options = {}) {
998
1217
  const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
999
1218
  const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
@@ -1009,16 +1228,46 @@ function ensureFabric() {
1009
1228
  const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
1010
1229
 
1011
1230
  return {
1012
- sx: sourceX,
1013
- sy: sourceY,
1014
- sw: Math.max(1, endX - sourceX),
1015
- sh: Math.max(1, endY - sourceY)
1231
+ sourceX,
1232
+ sourceY,
1233
+ sourceWidth: Math.max(1, endX - sourceX),
1234
+ sourceHeight: Math.max(1, endY - sourceY)
1016
1235
  };
1017
1236
  }
1018
1237
 
1238
+ /**
1239
+ * Crops an image data URL to a source region using an offscreen canvas.
1240
+ *
1241
+ * @param {string} dataUrl - Source image data URL.
1242
+ * @param {number} sourceX - Source region x coordinate.
1243
+ * @param {number} sourceY - Source region y coordinate.
1244
+ * @param {number} sourceWidth - Source region width.
1245
+ * @param {number} sourceHeight - Source region height.
1246
+ * @param {number} multiplier - Export multiplier already applied to the source data URL.
1247
+ * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1248
+ * @param {number} [quality=0.92] - Output image quality for lossy formats.
1249
+ * @returns {Promise<string>} Resolves with the cropped image data URL.
1250
+ * @private
1251
+ */
1019
1252
  async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1020
1253
  return new Promise((resolve, reject) => {
1021
1254
  const imageElement = new Image();
1255
+ let isSettled = false;
1256
+ const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1257
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
1258
+ let timerId;
1259
+ const settle = (callback) => {
1260
+ if (isSettled) return;
1261
+ isSettled = true;
1262
+ clearTimeout(timerId);
1263
+ imageElement.onload = null;
1264
+ imageElement.onerror = null;
1265
+ callback();
1266
+ };
1267
+ timerId = setTimeout(() => {
1268
+ settle(() => reject(new Error('Image crop load timed out')));
1269
+ try { imageElement.src = ''; } catch (error) { void error; }
1270
+ }, safeTimeoutMs);
1022
1271
  imageElement.onload = () => {
1023
1272
  try {
1024
1273
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
@@ -1030,19 +1279,34 @@ function ensureFabric() {
1030
1279
  offscreenCanvas.width = scaledSourceWidth;
1031
1280
  offscreenCanvas.height = scaledSourceHeight;
1032
1281
  const context = offscreenCanvas.getContext('2d');
1282
+ if (!context) throw new Error('2D canvas context is unavailable');
1033
1283
 
1034
1284
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1035
- resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
1285
+ settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1036
1286
  } catch (error) {
1037
- reject(error);
1287
+ settle(() => reject(error));
1038
1288
  }
1039
1289
  };
1040
- imageElement.onerror = reject;
1290
+ imageElement.onerror = (error) => settle(() => reject(error));
1041
1291
  imageElement.src = dataUrl;
1042
1292
  });
1043
1293
  }
1044
1294
 
1045
- async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1295
+ /**
1296
+ * Exports the whole Fabric canvas, then crops the requested source region from that export.
1297
+ *
1298
+ * @param {Object} region - Canvas source region and export options.
1299
+ * @param {number} region.sourceX - Source region x coordinate.
1300
+ * @param {number} region.sourceY - Source region y coordinate.
1301
+ * @param {number} region.sourceWidth - Source region width.
1302
+ * @param {number} region.sourceHeight - Source region height.
1303
+ * @param {number} [region.multiplier=1] - Export multiplier.
1304
+ * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1305
+ * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1306
+ * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1307
+ * @private
1308
+ */
1309
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1046
1310
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1047
1311
  const fullDataUrl = this.canvas.toDataURL({
1048
1312
  format,
@@ -1050,7 +1314,7 @@ function ensureFabric() {
1050
1314
  multiplier: safeMultiplier
1051
1315
  });
1052
1316
 
1053
- return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
1317
+ return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1054
1318
  }
1055
1319
 
1056
1320
  /**
@@ -1117,23 +1381,60 @@ function ensureFabric() {
1117
1381
  this._setCanvasSizeInt(size.width, size.height);
1118
1382
  }
1119
1383
 
1120
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1121
- if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage) return;
1384
+ /**
1385
+ * Whether post-load edits should resize the canvas to keep transformed content visible.
1386
+ *
1387
+ * @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
1388
+ * @private
1389
+ */
1390
+ _shouldResizeCanvasToContentBounds() {
1391
+ return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
1392
+ }
1393
+
1394
+ /**
1395
+ * Expands the canvas once so all provided objects remain visible after an edit.
1396
+ *
1397
+ * @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
1398
+ * @param {number} [padding=10] - Extra canvas space after the farthest object edge.
1399
+ * @returns {void}
1400
+ * @private
1401
+ */
1402
+ _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1403
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1122
1404
  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);
1405
+ let requiredWidth = this.canvas.getWidth();
1406
+ let requiredHeight = this.canvas.getHeight();
1407
+ fabricObjects.forEach(fabricObject => {
1408
+ if (!fabricObject) return;
1409
+ if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1410
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1411
+ requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1412
+ requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1413
+ });
1127
1414
  const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1128
1415
  const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1129
1416
  const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1130
1417
  const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1131
- this._setCanvasSizeInt(newWidth, newHeight);
1418
+ if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1419
+ this._setCanvasSizeInt(newWidth, newHeight);
1420
+ }
1132
1421
  } catch (error) {
1133
- this._reportWarning('expandCanvasToFitObject: failed to expand canvas', error);
1422
+ this._reportWarning('expandCanvasToFitObjects: failed to expand canvas', error);
1134
1423
  }
1135
1424
  }
1136
1425
 
1426
+ /**
1427
+ * Expands the canvas so one object remains visible after an edit.
1428
+ *
1429
+ * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1430
+ * @param {number} [padding=10] - Extra canvas space after the object edge.
1431
+ * @returns {void}
1432
+ * @private
1433
+ */
1434
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1435
+ this._expandCanvasToFitObjects([fabricObject], padding);
1436
+ }
1437
+
1137
1438
  /**
1138
1439
  * Scales the original image by a given factor, with animation.
1139
1440
  * Returns a promise that resolves when the scale animation is complete.
@@ -1142,7 +1443,7 @@ function ensureFabric() {
1142
1443
  * @public
1143
1444
  */
1144
1445
  scaleImage(factor, options = {}) {
1145
- return this.animQueue.add(() => this._scaleImageImpl(factor, options));
1446
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1146
1447
  }
1147
1448
 
1148
1449
  /**
@@ -1186,7 +1487,7 @@ function ensureFabric() {
1186
1487
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1187
1488
  this.originalImage.setCoords();
1188
1489
 
1189
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1490
+ if (this._shouldResizeCanvasToContentBounds()) {
1190
1491
  this._updateCanvasSizeToImageBounds();
1191
1492
  }
1192
1493
 
@@ -1213,7 +1514,7 @@ function ensureFabric() {
1213
1514
  * @public
1214
1515
  */
1215
1516
  rotateImage(degrees, options = {}) {
1216
- return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
1517
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1217
1518
  }
1218
1519
 
1219
1520
  /**
@@ -1247,7 +1548,7 @@ function ensureFabric() {
1247
1548
  this.originalImage.set('angle', degrees);
1248
1549
  this.originalImage.setCoords();
1249
1550
 
1250
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1551
+ if (this._shouldResizeCanvasToContentBounds()) {
1251
1552
  this._updateCanvasSizeToImageBounds();
1252
1553
  }
1253
1554
 
@@ -1271,43 +1572,52 @@ function ensureFabric() {
1271
1572
 
1272
1573
  /**
1273
1574
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
1274
- * @returns {Promise<void>} Promise that resolves when reset is complete.
1575
+ *
1576
+ * @returns {Promise<void>} Resolves when the reset history transition has been recorded.
1577
+ * @public
1275
1578
  */
1276
1579
  resetImageTransform() {
1277
1580
  if (!this.originalImage) return Promise.resolve();
1278
1581
 
1279
- return this.animQueue.add(async () => {
1280
- const before = this._serializeCanvasState();
1582
+ return this.animationQueue.add(async () => {
1583
+ const before = this._lastSnapshot || this._serializeCanvasState();
1281
1584
  await this._scaleImageImpl(1, { saveHistory: false });
1282
1585
  await this._rotateImageImpl(0, { saveHistory: false });
1283
1586
  const after = this._serializeCanvasState();
1284
1587
  this._pushStateTransition(before, after);
1285
- }).catch(err => {
1286
- this._reportError('resetImageTransform() failed', err);
1588
+ }).catch(error => {
1589
+ this._reportError('resetImageTransform() failed', error);
1287
1590
  });
1288
1591
  }
1289
1592
 
1290
1593
  /**
1291
- * @deprecated Use resetImageTransform() instead.
1594
+ * Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
1595
+ *
1596
+ * @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
1597
+ * @returns {Promise<void>} Resolves when the image transform reset is complete.
1292
1598
  */
1293
1599
  reset() {
1294
1600
  return this.resetImageTransform();
1295
1601
  }
1296
1602
 
1297
1603
  /**
1298
- * Restores a canvas state that was previously stored by saveState().
1299
- * @param {string} jsonString - the JSON string returned by fabric.toJSON().
1604
+ * Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
1605
+ *
1606
+ * @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
1607
+ * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1608
+ * @public
1300
1609
  */
1301
- loadFromState(jsonString) {
1302
- if (!jsonString || !this.canvas) return Promise.resolve();
1610
+ loadFromState(serializedState) {
1611
+ if (!serializedState || !this.canvas) return Promise.resolve();
1303
1612
 
1304
1613
  return new Promise((resolve) => {
1305
1614
  try {
1306
- const json = (typeof jsonString === 'string')
1307
- ? JSON.parse(jsonString)
1308
- : jsonString;
1615
+ const state = (typeof serializedState === 'string')
1616
+ ? JSON.parse(serializedState)
1617
+ : serializedState;
1618
+ const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1309
1619
 
1310
- this.canvas.loadFromJSON(json, () => {
1620
+ this.canvas.loadFromJSON(state, () => {
1311
1621
  try {
1312
1622
  this._hideAllMaskLabels();
1313
1623
  const canvasObjects = this.canvas.getObjects();
@@ -1316,11 +1626,27 @@ function ensureFabric() {
1316
1626
  if (this.originalImage) {
1317
1627
  this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
1318
1628
  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;
1629
+ const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
1630
+ const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
1631
+ const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
1632
+
1633
+ if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
1634
+ this.baseImageScale = restoredBaseScale;
1635
+ }
1636
+
1637
+ if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
1638
+ this.currentScale = restoredCurrentScale;
1639
+ } else {
1640
+ const baseScale = Number(this.baseImageScale) || 1;
1641
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1642
+ this.currentScale = imageScale / baseScale;
1643
+ }
1644
+
1645
+ this.currentRotation = Number.isFinite(restoredCurrentRotation)
1646
+ ? restoredCurrentRotation
1647
+ : (Number(this.originalImage.angle) || 0);
1323
1648
  } else {
1649
+ this.baseImageScale = 1;
1324
1650
  this.currentScale = 1;
1325
1651
  this.currentRotation = 0;
1326
1652
  }
@@ -1331,8 +1657,12 @@ function ensureFabric() {
1331
1657
  this._rebindMaskEvents(mask);
1332
1658
  mask.set(this._getMaskNormalStyle(mask));
1333
1659
  });
1334
- this.maskCounter = masks.reduce((max, mask) =>
1660
+ const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
1661
+ const maxMaskId = masks.reduce((max, mask) =>
1335
1662
  Math.max(max, mask.maskId), 0);
1663
+ this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId
1664
+ ? Math.floor(restoredMaskCounter)
1665
+ : maxMaskId;
1336
1666
  this._lastMask = masks.length ? masks[masks.length - 1] : null;
1337
1667
  if (!this._lastMask) {
1338
1668
  this._lastMaskInitialLeft = null;
@@ -1362,12 +1692,17 @@ function ensureFabric() {
1362
1692
  }
1363
1693
 
1364
1694
  /**
1365
- * Saves the current state of the canvas to history, storing any mask/raster label information.
1695
+ * Saves the current editable canvas state as an undoable history transition.
1696
+ *
1697
+ * Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
1698
+ * mask objects and restored by `loadFromState()`.
1699
+ *
1700
+ * @returns {void}
1701
+ * @public
1366
1702
  */
1367
1703
  saveState() {
1368
1704
  if (!this.canvas) return;
1369
1705
  const activeObject = this.canvas.getActiveObject();
1370
- this._hideAllMaskLabels();
1371
1706
 
1372
1707
  try {
1373
1708
  const after = this._serializeCanvasState();
@@ -1391,13 +1726,24 @@ function ensureFabric() {
1391
1726
  } catch (error) {
1392
1727
  this._reportWarning('saveState: failed to save canvas snapshot', error);
1393
1728
  } finally {
1394
- if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
1729
+ if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1395
1730
  this._handleSelectionChanged([activeObject]);
1396
1731
  }
1397
1732
  this._updateUI();
1398
1733
  }
1399
1734
  }
1400
1735
 
1736
+ /**
1737
+ * Pushes a precomputed before/after state transition into history.
1738
+ *
1739
+ * Use this for operations such as crop and merge that build their snapshots around asynchronous image
1740
+ * loading, where the "after" state is already applied before the history command is recorded.
1741
+ *
1742
+ * @param {string} before - Serialized state before the operation.
1743
+ * @param {string} after - Serialized state after the operation.
1744
+ * @returns {void}
1745
+ * @private
1746
+ */
1401
1747
  _pushStateTransition(before, after) {
1402
1748
  if (!before || !after) return;
1403
1749
  if (before === after) return;
@@ -1414,6 +1760,9 @@ function ensureFabric() {
1414
1760
 
1415
1761
  /**
1416
1762
  * Undo the last state change, if possible.
1763
+ *
1764
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
1765
+ * @public
1417
1766
  */
1418
1767
  undo() {
1419
1768
  return this.historyManager.undo()
@@ -1423,6 +1772,9 @@ function ensureFabric() {
1423
1772
 
1424
1773
  /**
1425
1774
  * Redo the next state change, if possible.
1775
+ *
1776
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
1777
+ * @public
1426
1778
  */
1427
1779
  redo() {
1428
1780
  return this.historyManager.redo()
@@ -1436,7 +1788,7 @@ function ensureFabric() {
1436
1788
  try {
1437
1789
  mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
1438
1790
  mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
1439
- } catch (e) { void e; }
1791
+ } catch (error) { void error; }
1440
1792
  }
1441
1793
 
1442
1794
  const metadata = {};
@@ -1474,29 +1826,38 @@ function ensureFabric() {
1474
1826
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
1475
1827
  }
1476
1828
 
1477
- /**
1829
+ /**
1478
1830
  * 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.
1831
+ *
1832
+ * Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
1833
+ * after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
1834
+ * so controls and hit testing stay in sync with Fabric 5.x behavior.
1835
+ *
1836
+ * @param {Object} [config={}] - Optional mask configuration overrides.
1837
+ * @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
1838
+ * @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
1839
+ * @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
1840
+ * @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
1841
+ * @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
1842
+ * @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
1843
+ * @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
1844
+ * @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
1845
+ * @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
1846
+ * @param {number} [config.angle=0] - Rotation angle in degrees.
1847
+ * @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
1848
+ * @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
1849
+ * @param {boolean} [config.selectable=true] - Whether the mask can be selected.
1850
+ * @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
1851
+ * @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
1852
+ * @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
1853
+ * @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
1854
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1494
1855
  * @public
1495
1856
  */
1496
1857
  createMask(config = {}) {
1497
1858
  if (!this.canvas) return null;
1498
1859
  const shapeType = config.shape || 'rect';
1499
- // Default config
1860
+ // Normalize mask defaults before applying caller-provided overrides.
1500
1861
  const maskConfig = {
1501
1862
  shape: shapeType,
1502
1863
  width: this.options.defaultMaskWidth,
@@ -1513,18 +1874,27 @@ function ensureFabric() {
1513
1874
 
1514
1875
  // Always start placement relative to canvas left/top.
1515
1876
  const firstOffset = 10;
1516
- let left = firstOffset;
1517
- let top = firstOffset;
1877
+ let left;
1878
+ let top;
1879
+
1880
+ const getCanvasBasis = (axis) => {
1881
+ const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
1882
+ const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
1883
+ if (axis === 'height') return canvasHeight;
1884
+ if (axis === 'min') return Math.min(canvasWidth, canvasHeight);
1885
+ return canvasWidth;
1886
+ };
1518
1887
 
1519
- const resolveValue = (value, fallback) => {
1888
+ const resolveValue = (value, fallback, axis = 'width') => {
1520
1889
  if (typeof value === 'function')
1521
1890
  return value(this.canvas, this.options);
1522
1891
  if (typeof value === 'string' && value.endsWith('%')) {
1523
- const percent = parseFloat(value) / 100;
1524
- return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1892
+ const percent = Number.parseFloat(value) / 100;
1893
+ if (!Number.isFinite(percent)) return fallback;
1894
+ return Math.floor(getCanvasBasis(axis) * percent);
1525
1895
  }
1526
1896
  return value != null ? value : fallback;
1527
- }
1897
+ };
1528
1898
 
1529
1899
  if (maskConfig.left === undefined && this._lastMask) {
1530
1900
  const previousMask = this._lastMask;
@@ -1538,12 +1908,14 @@ function ensureFabric() {
1538
1908
  left = Math.round(previousMaskRight + maskConfig.gap);
1539
1909
  top = previousMask.top ?? firstOffset;
1540
1910
  } else {
1541
- left = resolveValue(maskConfig.left, firstOffset);
1542
- top = resolveValue(maskConfig.top, firstOffset);
1911
+ left = resolveValue(maskConfig.left, firstOffset, 'width');
1912
+ top = resolveValue(maskConfig.top, firstOffset, 'height');
1543
1913
  }
1544
1914
 
1545
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1546
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1915
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
1916
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
1917
+ maskConfig.left = left;
1918
+ maskConfig.top = top;
1547
1919
 
1548
1920
  let mask;
1549
1921
  if (typeof maskConfig.fabricGenerator === 'function') {
@@ -1553,7 +1925,7 @@ function ensureFabric() {
1553
1925
  case 'circle':
1554
1926
  mask = new fabric.Circle({
1555
1927
  left, top,
1556
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1928
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
1557
1929
  fill: maskConfig.color,
1558
1930
  opacity: maskConfig.alpha,
1559
1931
  angle: maskConfig.angle,
@@ -1563,8 +1935,8 @@ function ensureFabric() {
1563
1935
  case 'ellipse':
1564
1936
  mask = new fabric.Ellipse({
1565
1937
  left, top,
1566
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1567
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1938
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
1939
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
1568
1940
  fill: maskConfig.color,
1569
1941
  opacity: maskConfig.alpha,
1570
1942
  angle: maskConfig.angle,
@@ -1573,9 +1945,11 @@ function ensureFabric() {
1573
1945
  break;
1574
1946
  case 'polygon': {
1575
1947
  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) }));
1948
+ if (Array.isArray(polygonPoints) && polygonPoints.length) {
1949
+ // Ensure numeric {x,y} objects for fabric.Polygon.
1950
+ polygonPoints = polygonPoints.map(point => Array.isArray(point)
1951
+ ? { x: Number(point[0]), y: Number(point[1]) }
1952
+ : { x: Number(point.x), y: Number(point.y) });
1579
1953
  }
1580
1954
  mask = new fabric.Polygon(polygonPoints, {
1581
1955
  left, top,
@@ -1590,12 +1964,12 @@ function ensureFabric() {
1590
1964
  default:
1591
1965
  mask = new fabric.Rect({
1592
1966
  left, top,
1593
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1594
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1967
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
1968
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
1595
1969
  fill: maskConfig.color,
1596
1970
  opacity: maskConfig.alpha,
1597
1971
  angle: maskConfig.angle,
1598
- rx: maskConfig.rx, // Rounded Corners
1972
+ rx: maskConfig.rx,
1599
1973
  ry: maskConfig.ry,
1600
1974
  ...maskConfig.styles
1601
1975
  });
@@ -1614,6 +1988,7 @@ function ensureFabric() {
1614
1988
  transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
1615
1989
  stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
1616
1990
  strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
1991
+ opacity: hasStyle('opacity') ? styles.opacity : maskConfig.alpha,
1617
1992
  strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
1618
1993
  };
1619
1994
  if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
@@ -1621,17 +1996,17 @@ function ensureFabric() {
1621
1996
  mask.setCoords();
1622
1997
 
1623
1998
  mask.set({
1624
- originalAlpha: maskConfig.alpha,
1999
+ originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
1625
2000
  originalStroke: mask.stroke || '#ccc',
1626
2001
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1627
2002
  });
1628
2003
  this._rebindMaskEvents(mask);
1629
2004
  this._expandCanvasToFitObject(mask);
1630
2005
 
1631
- // Remember initial for next one
2006
+ // Store placement values so the next mask can be positioned beside this one.
1632
2007
  this._lastMaskInitialLeft = left;
1633
2008
  this._lastMaskInitialTop = top;
1634
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
2009
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
1635
2010
 
1636
2011
  const maskId = ++this.maskCounter;
1637
2012
  mask.set({
@@ -1654,7 +2029,11 @@ function ensureFabric() {
1654
2029
  }
1655
2030
 
1656
2031
  /**
1657
- * @deprecated Use createMask() instead.
2032
+ * Backward-compatible alias for {@link ImageEditor#createMask}.
2033
+ *
2034
+ * @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
2035
+ * @param {Object} [config={}] - Mask configuration passed to createMask().
2036
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1658
2037
  */
1659
2038
  addMask(config = {}) {
1660
2039
  return this.createMask(config);
@@ -1727,6 +2106,24 @@ function ensureFabric() {
1727
2106
  }
1728
2107
  }
1729
2108
 
2109
+ /**
2110
+ * Returns a stable zero-based creation index for label callbacks.
2111
+ *
2112
+ * Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
2113
+ * lifetime of a mask.
2114
+ *
2115
+ * @param {fabric.Object} mask - Mask object.
2116
+ * @returns {number} Stable zero-based creation index.
2117
+ * @private
2118
+ */
2119
+ _getMaskCreationIndex(mask) {
2120
+ const maskId = Number(mask && mask.maskId);
2121
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
2122
+
2123
+ const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
2124
+ return Math.max(0, masks.indexOf(mask));
2125
+ }
2126
+
1730
2127
  /**
1731
2128
  * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1732
2129
  * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
@@ -1757,9 +2154,7 @@ function ensureFabric() {
1757
2154
  };
1758
2155
  if (this.options.label) {
1759
2156
  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);
2157
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
1763
2158
  }
1764
2159
  // Merge external styles
1765
2160
  if (this.options.label.textOptions) {
@@ -1924,10 +2319,14 @@ function ensureFabric() {
1924
2319
  }
1925
2320
 
1926
2321
  /**
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.
2322
+ * Flattens the current masks into the base image and reloads the flattened image.
2323
+ *
2324
+ * This removes editable mask objects after export and records the operation as one undoable history transition.
2325
+ * It does nothing when no base image or no masks exist.
2326
+ *
1929
2327
  * @async
1930
- * @returns {Promise<void>} Resolves when merge and load are complete.
2328
+ * @returns {Promise<void>} Resolves when the flattened image has been loaded.
2329
+ * @public
1931
2330
  */
1932
2331
  async mergeMasks() {
1933
2332
  if (!this.originalImage) return;
@@ -1941,53 +2340,62 @@ function ensureFabric() {
1941
2340
  const beforeJson = this._serializeCanvasState();
1942
2341
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1943
2342
  this.removeAllMasks({ saveHistory: false });
1944
- await this.loadImage(merged);
2343
+ await this.loadImage(merged, { preserveScroll: true });
1945
2344
  const afterJson = this._serializeCanvasState();
1946
2345
  this._pushStateTransition(beforeJson, afterJson);
1947
- } catch (err) {
1948
- this._reportError('merge error', err);
2346
+ } catch (error) {
2347
+ this._reportError('merge error', error);
1949
2348
  }
1950
2349
  }
1951
2350
 
1952
2351
  /**
1953
- * @deprecated Use mergeMasks() instead.
2352
+ * Backward-compatible alias for {@link ImageEditor#mergeMasks}.
2353
+ *
2354
+ * @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
2355
+ * @returns {Promise<void>} Resolves when mask flattening is complete.
1954
2356
  */
1955
2357
  async merge() {
1956
2358
  return this.mergeMasks();
1957
2359
  }
1958
2360
 
1959
2361
  /**
1960
- * Triggers a JPEG image download of the current canvas (image plus masks if configured).
2362
+ * Triggers a JPEG image download of the current canvas.
2363
+ *
1961
2364
  * The image area and multiplier are controlled by options.
1962
2365
  * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
2366
+ * @returns {void}
2367
+ * @public
1963
2368
  */
1964
2369
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1965
2370
  if (!this.originalImage) return;
1966
2371
  const exportImageArea = this.options.exportImageAreaByDefault;
1967
2372
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1968
- .then(base64 => {
2373
+ .then(imageBase64 => {
1969
2374
  const link = document.createElement('a');
1970
2375
  link.download = fileName;
1971
- link.href = base64;
2376
+ link.href = imageBase64;
1972
2377
  document.body.appendChild(link);
1973
2378
  link.click();
1974
2379
  document.body.removeChild(link);
1975
2380
  })
1976
- .catch(err => this._reportError('download error', err));
2381
+ .catch(error => this._reportError('download error', error));
1977
2382
  }
1978
2383
 
1979
2384
  /**
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.
2385
+ * Exports the current image as a Base64-encoded data URL.
2386
+ *
2387
+ * When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
2388
+ * temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
2389
+ *
1983
2390
  * @async
1984
2391
  * @param {Object} [options={}] - Export options.
1985
2392
  * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1986
2393
  * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
1987
2394
  * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
1988
2395
  * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
1989
- * @returns {Promise<string>} Promise resolving to an image data URL.
2396
+ * @returns {Promise<string>} Resolves with an image data URL.
1990
2397
  * @throws {Error} If there is no image loaded.
2398
+ * @public
1991
2399
  */
1992
2400
  async exportImageBase64(options = {}) {
1993
2401
  if (!this.originalImage) throw new Error('No image loaded');
@@ -2007,12 +2415,9 @@ function ensureFabric() {
2007
2415
 
2008
2416
  this.originalImage.setCoords();
2009
2417
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2010
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2418
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2011
2419
  return await this._exportCanvasRegionToDataURL({
2012
- sx,
2013
- sy,
2014
- sw,
2015
- sh,
2420
+ ...exportRegion,
2016
2421
  multiplier,
2017
2422
  quality,
2018
2423
  format
@@ -2025,7 +2430,7 @@ function ensureFabric() {
2025
2430
  }
2026
2431
  }
2027
2432
 
2028
- // Export current scaled image area (masks clipped)
2433
+ // Render masks as export shapes without mutating their editable styles.
2029
2434
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2030
2435
  const maskStyleBackups = masks.map(mask => ({
2031
2436
  object: mask,
@@ -2039,29 +2444,26 @@ function ensureFabric() {
2039
2444
 
2040
2445
  let finalBase64;
2041
2446
  try {
2042
- // Remove labels, deselect
2447
+ // Labels are UI overlays and should not be part of the flattened export.
2043
2448
  masks.forEach(mask => this._removeLabelForMask(mask));
2044
2449
  this.canvas.discardActiveObject();
2045
2450
  this.canvas.renderAll();
2046
2451
 
2047
- // Set masks to opaque black no border
2452
+ // The export treats masks as opaque shapes with no editable border.
2048
2453
  masks.forEach(mask => {
2049
2454
  mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
2050
2455
  mask.setCoords();
2051
2456
  });
2052
2457
  this.canvas.renderAll();
2053
2458
 
2054
- // Compute integer bounding box for image
2459
+ // Compute an integer canvas region for the base image.
2055
2460
  this.originalImage.setCoords();
2056
2461
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2057
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2462
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2058
2463
 
2059
2464
  // Crop precisely in offscreen canvas
2060
2465
  finalBase64 = await this._exportCanvasRegionToDataURL({
2061
- sx,
2062
- sy,
2063
- sw,
2064
- sh,
2466
+ ...exportRegion,
2065
2467
  multiplier,
2066
2468
  quality,
2067
2469
  format
@@ -2088,15 +2490,21 @@ function ensureFabric() {
2088
2490
  }
2089
2491
 
2090
2492
  /**
2091
- * @deprecated Use exportImageBase64() instead.
2493
+ * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
2494
+ *
2495
+ * @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
2496
+ * @param {Object} [options={}] - Export options passed to exportImageBase64().
2497
+ * @returns {Promise<string>} Resolves with an image data URL.
2092
2498
  */
2093
2499
  async getImageBase64(options = {}) {
2094
2500
  return this.exportImageBase64(options);
2095
2501
  }
2096
2502
 
2097
2503
  /**
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).
2504
+ * Exports the current image as a File object.
2505
+ *
2506
+ * The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
2507
+ * Supported output formats are JPEG, PNG, and WebP.
2100
2508
  *
2101
2509
  * @async
2102
2510
  * @param {Object} [options={}] - Export options.
@@ -2122,17 +2530,17 @@ function ensureFabric() {
2122
2530
 
2123
2531
  const safeFileType = this._normalizeImageFormat(fileType);
2124
2532
 
2125
- // Get Base64
2126
- let base64;
2533
+ // Generate the data URL in the requested export mode.
2534
+ let imageBase64;
2127
2535
  if (mergeMask) {
2128
- base64 = await this.exportImageBase64({
2536
+ imageBase64 = await this.exportImageBase64({
2129
2537
  exportImageArea: true,
2130
2538
  multiplier,
2131
2539
  quality,
2132
2540
  fileType: safeFileType
2133
2541
  });
2134
2542
  } else {
2135
- base64 = await this.exportImageBase64({
2543
+ imageBase64 = await this.exportImageBase64({
2136
2544
  exportImageArea: false,
2137
2545
  multiplier,
2138
2546
  quality,
@@ -2141,9 +2549,9 @@ function ensureFabric() {
2141
2549
  }
2142
2550
 
2143
2551
  // Convert to the required image format
2144
- let imageDataUrl = base64;
2552
+ let imageDataUrl = imageBase64;
2145
2553
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
2146
- // Redraw if not required format
2554
+ // Redraw the exported data URL when the browser returned a different image format.
2147
2555
  imageDataUrl = await new Promise((resolve, reject) => {
2148
2556
  const imageElement = new window.Image();
2149
2557
  imageElement.crossOrigin = "Anonymous";
@@ -2159,11 +2567,11 @@ function ensureFabric() {
2159
2567
  } catch (error) { reject(error); }
2160
2568
  };
2161
2569
  imageElement.onerror = reject;
2162
- imageElement.src = base64;
2570
+ imageElement.src = imageBase64;
2163
2571
  });
2164
2572
  }
2165
2573
 
2166
- // Convert DataURL to Blob and then to File
2574
+ // Convert the final data URL to a File with the requested MIME type.
2167
2575
  const binaryString = atob(imageDataUrl.split(',')[1]);
2168
2576
  const mime = `image/${safeFileType}`;
2169
2577
  let byteIndex = binaryString.length;
@@ -2237,7 +2645,12 @@ function ensureFabric() {
2237
2645
  }
2238
2646
 
2239
2647
  /**
2240
- * Enter crop mode: create a resizable/movable selection rect on top of the image.
2648
+ * Enters crop mode by creating a resizable crop rectangle above the base image.
2649
+ *
2650
+ * Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
2651
+ * cropping when `crop.hideMasksDuringCrop` is enabled.
2652
+ *
2653
+ * @returns {void}
2241
2654
  * @public
2242
2655
  */
2243
2656
  enterCropMode() {
@@ -2245,24 +2658,30 @@ function ensureFabric() {
2245
2658
  if (!this.isImageLoaded()) return;
2246
2659
  this._cropMode = true;
2247
2660
 
2248
- // Disable canvas group selection to avoid accidental group selection while cropping
2661
+ // Disable group selection so only the crop rectangle can be manipulated.
2249
2662
  this._prevSelectionSetting = this.canvas.selection;
2250
2663
  this.canvas.selection = false;
2251
2664
 
2252
- // Make sure no active object
2665
+ // Clear the current selection before activating the crop rectangle.
2253
2666
  this.canvas.discardActiveObject();
2254
2667
 
2255
- // Create initial crop rect centered on the image bounding box
2668
+ // Create the initial crop rectangle inside the image bounds.
2256
2669
  this.originalImage.setCoords();
2257
2670
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2258
- // Provide small inset so user can see a margin
2671
+ // Use a small inset so the user can see the crop boundary.
2259
2672
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
2260
2673
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
2261
2674
  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
2675
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2676
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
2677
+ const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
2678
+ const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
2679
+ const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
2680
+ const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
2681
+ const width = minCropWidth;
2682
+ const height = minCropHeight;
2683
+
2684
+ // Visual style for the temporary crop rectangle.
2266
2685
  const cropRect = new fabric.Rect({
2267
2686
  left, top,
2268
2687
  width, height,
@@ -2277,7 +2696,8 @@ function ensureFabric() {
2277
2696
  cornerSize: 8,
2278
2697
  objectCaching: false,
2279
2698
  originX: 'left',
2280
- originY: 'top'
2699
+ originY: 'top',
2700
+ lockScalingFlip: true
2281
2701
  });
2282
2702
 
2283
2703
  // Ensure the crop rect is above everything
@@ -2286,11 +2706,10 @@ function ensureFabric() {
2286
2706
  this.canvas.bringToFront(cropRect);
2287
2707
  this.canvas.setActiveObject(cropRect);
2288
2708
 
2289
- // Keep reference
2709
+ // Store the crop rectangle so apply/cancel can clean it up.
2290
2710
  this._cropRect = cropRect;
2291
2711
 
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.
2712
+ // Keep only the crop rectangle interactive while preserving each object's previous state.
2294
2713
  this._cropPrevEvented = [];
2295
2714
  const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
2296
2715
  this.canvas.getObjects().forEach(object => {
@@ -2307,13 +2726,23 @@ function ensureFabric() {
2307
2726
  }
2308
2727
  });
2309
2728
 
2310
- // When the crop rect changes, re-render
2311
- const handleCropRectModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (error) { void error; } };
2729
+ // Keep Fabric controls and configured size limits in sync as the crop rectangle changes.
2730
+ const handleCropRectModified = () => {
2731
+ try {
2732
+ const cropWidth = Math.max(1, Number(cropRect.width) || 1);
2733
+ const cropHeight = Math.max(1, Number(cropRect.height) || 1);
2734
+ const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
2735
+ const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2736
+ cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2737
+ cropRect.setCoords();
2738
+ this.canvas.requestRenderAll();
2739
+ } catch (error) { void error; }
2740
+ };
2312
2741
  cropRect.on('modified', handleCropRectModified);
2313
2742
  cropRect.on('moving', handleCropRectModified);
2314
2743
  cropRect.on('scaling', handleCropRectModified);
2315
2744
 
2316
- // Keep handlers to remove later
2745
+ // Store handlers so cancel/apply/dispose can unbind them.
2317
2746
  this._cropHandlers.push({
2318
2747
  target: cropRect,
2319
2748
  handlers: [
@@ -2328,7 +2757,9 @@ function ensureFabric() {
2328
2757
  }
2329
2758
 
2330
2759
  /**
2331
- * Cancel crop mode and remove the temporary selection rect.
2760
+ * Cancels crop mode and removes the temporary crop rectangle.
2761
+ *
2762
+ * @returns {void}
2332
2763
  * @public
2333
2764
  */
2334
2765
  cancelCrop() {
@@ -2336,7 +2767,7 @@ function ensureFabric() {
2336
2767
  this._removeCropRect();
2337
2768
  this._restoreCropObjectState();
2338
2769
  this._cropMode = false;
2339
- // restore selection setting
2770
+ // Restore the canvas selection setting that was active before crop mode.
2340
2771
  this.canvas.selection = !!this._prevSelectionSetting;
2341
2772
  this._prevSelectionSetting = undefined;
2342
2773
 
@@ -2346,23 +2777,29 @@ function ensureFabric() {
2346
2777
  }
2347
2778
 
2348
2779
  /**
2349
- * Apply the current crop rectangle.
2350
- * remove all masks and export canvas snapshot and crop via offscreen canvas
2780
+ * Applies the current crop rectangle to the base image.
2781
+ *
2782
+ * Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
2783
+ * region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
2784
+ * single undoable history transition.
2785
+ *
2786
+ * @async
2787
+ * @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
2351
2788
  * @public
2352
2789
  */
2353
2790
  async applyCrop() {
2354
2791
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2355
2792
 
2356
- // Ensure crop rect coords are fresh
2793
+ // Fabric does not update control coordinates automatically after programmatic transforms.
2357
2794
  this._cropRect.setCoords();
2358
2795
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2359
2796
 
2360
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
2797
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
2361
2798
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2362
2799
 
2363
2800
  this._restoreCropObjectState();
2364
2801
 
2365
- let beforeJson = null;
2802
+ let beforeJson;
2366
2803
  try {
2367
2804
  beforeJson = this._serializeCanvasState();
2368
2805
  } catch (error) {
@@ -2380,16 +2817,16 @@ function ensureFabric() {
2380
2817
  mask.setCoords();
2381
2818
  const maskBounds = mask.getBoundingRect(true, true);
2382
2819
  const intersectsCrop =
2383
- maskBounds.left < sx + sw &&
2384
- maskBounds.left + maskBounds.width > sx &&
2385
- maskBounds.top < sy + sh &&
2386
- maskBounds.top + maskBounds.height > sy;
2820
+ maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
2821
+ maskBounds.left + maskBounds.width > cropRegion.sourceX &&
2822
+ maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
2823
+ maskBounds.top + maskBounds.height > cropRegion.sourceY;
2387
2824
  this._removeLabelForMask(mask);
2388
2825
  this.canvas.remove(mask);
2389
2826
  if (shouldPreserveMasks && intersectsCrop) {
2390
2827
  mask.set({
2391
- left: (mask.left || 0) - sx,
2392
- top: (mask.top || 0) - sy,
2828
+ left: (mask.left || 0) - cropRegion.sourceX,
2829
+ top: (mask.top || 0) - cropRegion.sourceY,
2393
2830
  visible: true
2394
2831
  });
2395
2832
  mask.setCoords();
@@ -2409,19 +2846,16 @@ function ensureFabric() {
2409
2846
 
2410
2847
  this._removeCropRect();
2411
2848
 
2412
- // End crop mode
2849
+ // End crop mode before loading the cropped image.
2413
2850
  this._cropMode = false;
2414
2851
  this.canvas.selection = !!this._prevSelectionSetting;
2415
2852
  this._prevSelectionSetting = undefined;
2416
2853
 
2417
- // Export full canvas and crop on offscreen canvas
2854
+ // Export the crop region from the current canvas.
2418
2855
  let croppedBase64;
2419
2856
  try {
2420
2857
  croppedBase64 = await this._exportCanvasRegionToDataURL({
2421
- sx,
2422
- sy,
2423
- sw,
2424
- sh,
2858
+ ...cropRegion,
2425
2859
  multiplier: 1,
2426
2860
  quality: this._normalizeQuality(this.options.downsampleQuality),
2427
2861
  format: 'jpeg'
@@ -2431,7 +2865,7 @@ function ensureFabric() {
2431
2865
  return;
2432
2866
  }
2433
2867
 
2434
- // Load the cropped image as the new base image
2868
+ // Load the cropped image as the new base image.
2435
2869
  try {
2436
2870
  await this.loadImage(croppedBase64);
2437
2871
  if (preservedMasks.length) {
@@ -2445,27 +2879,27 @@ function ensureFabric() {
2445
2879
  this._updateMaskList();
2446
2880
  this.canvas.renderAll();
2447
2881
  }
2448
- } catch (e) {
2449
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', e);
2882
+ } catch (error) {
2883
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
2450
2884
  return;
2451
2885
  }
2452
2886
 
2453
- // Create "after" snapshot (also exclude crop rect if any) and push history command
2454
- let afterJson = null;
2887
+ // Create an after snapshot and push one history command for the crop operation.
2888
+ let afterJson;
2455
2889
  try {
2456
2890
  afterJson = this._serializeCanvasState();
2457
- } catch (e) {
2458
- this._reportWarning('applyCrop: failed to serialize after state', e);
2891
+ } catch (error) {
2892
+ this._reportWarning('applyCrop: failed to serialize after state', error);
2459
2893
  afterJson = null;
2460
2894
  }
2461
2895
 
2462
2896
  try {
2463
2897
  this._pushStateTransition(beforeJson, afterJson);
2464
- } catch (e) {
2465
- this._reportWarning('applyCrop: failed to push history command', e);
2898
+ } catch (error) {
2899
+ this._reportWarning('applyCrop: failed to push history command', error);
2466
2900
  }
2467
2901
 
2468
- // Final UI update
2902
+ // Refresh UI state after crop completion.
2469
2903
  this._updateUI();
2470
2904
  this.canvas.renderAll();
2471
2905
  }
@@ -2500,7 +2934,7 @@ function ensureFabric() {
2500
2934
  const isInCropMode = !!this._cropMode;
2501
2935
 
2502
2936
  if (isInCropMode) {
2503
- // iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
2937
+ // Disable all controls except the crop action buttons while crop mode is active.
2504
2938
  for (const key of Object.keys(this.elements || {})) {
2505
2939
  const element = document.getElementById(this.elements[key]);
2506
2940
  if (!element) continue;
@@ -2563,7 +2997,7 @@ function ensureFabric() {
2563
2997
  }
2564
2998
 
2565
2999
  /**
2566
- * Automatically display and hide placeholders and containers based on the current image content
3000
+ * Updates placeholder and canvas container visibility based on whether an image is loaded.
2567
3001
  * @private
2568
3002
  */
2569
3003
  _updatePlaceholderStatus() {
@@ -2572,21 +3006,30 @@ function ensureFabric() {
2572
3006
  }
2573
3007
 
2574
3008
  /**
2575
- * Controls the display/hiding of the Placeholder and Canvas container.
2576
- * @param {boolean} show - true displays the placeholder, false displays the canvas container
3009
+ * Shows or hides the placeholder and canvas container.
3010
+ *
3011
+ * @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
2577
3012
  * @private
2578
3013
  */
2579
3014
  _setPlaceholderVisible(show) {
2580
- if (!this.placeholderElement) return;
2581
- if (show) {
2582
- this.placeholderElement.classList.remove('d-none');
2583
- this.placeholderElement.classList.add('d-flex');
2584
- this.containerElement.classList.add('d-none');
2585
- } else {
2586
- this.placeholderElement.classList.remove('d-flex');
2587
- this.placeholderElement.classList.add('d-none');
2588
- this.containerElement.classList.remove('d-none');
2589
- }
3015
+ if (!this.placeholderElement || !this.containerElement) return;
3016
+ this._setElementVisible(this.placeholderElement, show);
3017
+ this._setElementVisible(this.containerElement, !show);
3018
+ }
3019
+
3020
+ /**
3021
+ * Updates element visibility.
3022
+ *
3023
+ * @param {HTMLElement} element - Element whose visibility should be updated.
3024
+ * @param {boolean} isVisible - If true, removes the hidden state.
3025
+ * @returns {void}
3026
+ * @private
3027
+ */
3028
+ _setElementVisible(element, isVisible) {
3029
+ if (!element) return;
3030
+ element.hidden = !isVisible;
3031
+ element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
3032
+ if (isVisible && element.classList) element.classList.remove('d-none');
2590
3033
  }
2591
3034
 
2592
3035
  /**
@@ -2608,16 +3051,16 @@ function ensureFabric() {
2608
3051
  } catch (error) { void error; }
2609
3052
 
2610
3053
  if (this._cropRect) {
2611
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
3054
+ try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
2612
3055
  this._cropRect = null;
2613
3056
  }
2614
3057
 
2615
3058
  if (this.containerElement && this._containerOriginalOverflow !== undefined) {
2616
- try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (e) { void e; }
3059
+ try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
2617
3060
  }
2618
3061
 
2619
3062
  if (this.canvas) {
2620
- try { this.canvas.dispose(); } catch (e) { void e; }
3063
+ try { this.canvas.dispose(); } catch (error) { void error; }
2621
3064
  this.canvas = null;
2622
3065
  this.canvasElement = null;
2623
3066
  this.isImageLoadedToCanvas = false;
@@ -2627,113 +3070,160 @@ function ensureFabric() {
2627
3070
  }
2628
3071
 
2629
3072
  /**
2630
- * A simple FIFO queue that guarantees animations are executed sequentially.
2631
- * @class AnimationQueue
3073
+ * @callback AnimationTaskCallback
3074
+ * @returns {unknown} Animation result or awaitable animation result.
3075
+ */
3076
+
3077
+ /**
3078
+ * @callback PromiseResolveCallback
3079
+ * @param {unknown} value - Promise resolution value.
3080
+ * @returns {void}
3081
+ */
3082
+
3083
+ /**
3084
+ * @callback PromiseRejectCallback
3085
+ * @param {unknown} reason - Promise rejection reason.
3086
+ * @returns {void}
3087
+ */
3088
+
3089
+ /**
3090
+ * @typedef {Object} QueuedAnimationTask
3091
+ * @property {AnimationTaskCallback} animationFn - Queued animation function.
3092
+ * @property {PromiseResolveCallback} resolve - Promise resolver for the queued animation.
3093
+ * @property {PromiseRejectCallback} reject - Promise rejecter for the queued animation.
3094
+ */
3095
+
3096
+ /**
3097
+ * @callback HistoryTaskCallback
3098
+ * @returns {void|Promise<void>} Result of a history operation.
3099
+ */
3100
+
3101
+ /**
3102
+ * FIFO queue that serializes transform animations so Fabric state changes do not overlap.
3103
+ *
3104
+ * @private
2632
3105
  */
2633
3106
  class AnimationQueue {
2634
3107
  /**
2635
- * Creates a new AnimationQueue.
2636
- *
2637
- * @constructor
3108
+ * Creates an empty animation queue.
2638
3109
  */
2639
3110
  constructor() {
2640
3111
  /**
2641
- * Internal queue holding animation descriptors.
2642
- * @type {Array<{fn: Function, resolve: Function, reject: Function}>}
3112
+ * Pending animation descriptors.
3113
+ * @type {Array<QueuedAnimationTask>}
2643
3114
  */
2644
- this.queue = [];
3115
+ this.animationTasks = [];
2645
3116
  /**
2646
- * Flag indicating whether an animation is currently running.
3117
+ * Whether an animation task is currently running.
2647
3118
  * @type {boolean}
2648
3119
  */
2649
- this.running = false;
3120
+ this.isRunning = false;
2650
3121
  }
2651
3122
 
2652
3123
  /**
2653
3124
  * Adds an animation function to the queue.
2654
3125
  *
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.
3126
+ * @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
3127
+ * @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
2657
3128
  */
2658
3129
  async add(animationFn) {
2659
3130
  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();
3131
+ this.animationTasks.push({ animationFn, resolve, reject });
3132
+ if (!this.isRunning) {
3133
+ this._drainQueue();
2665
3134
  }
2666
3135
  });
2667
3136
  }
2668
3137
 
2669
3138
  /**
2670
- * Internal helper that processes the animation queue sequentially until it is empty.
3139
+ * Runs queued animation tasks sequentially until the queue is empty.
2671
3140
  *
2672
3141
  * @private
2673
3142
  * @returns {Promise<void>}
2674
3143
  */
2675
- async processQueue() {
2676
- if (this.queue.length === 0) {
2677
- this.running = false;
3144
+ async _drainQueue() {
3145
+ if (this.animationTasks.length === 0) {
3146
+ this.isRunning = false;
2678
3147
  return;
2679
3148
  }
2680
3149
 
2681
- this.running = true;
2682
- const { fn, resolve, reject } = this.queue.shift();
3150
+ this.isRunning = true;
3151
+ const { animationFn, resolve, reject } = this.animationTasks.shift();
2683
3152
 
2684
3153
  try {
2685
- const result = await fn();
3154
+ const result = await animationFn();
2686
3155
  resolve(result);
2687
3156
  } catch (error) {
2688
3157
  reject(error);
2689
3158
  }
2690
3159
 
2691
- this.processQueue();
3160
+ await this._drainQueue();
2692
3161
  }
2693
3162
  }
2694
3163
 
2695
3164
  /**
2696
- * Command object encapsulating an executable action and its corresponding undo operation.
2697
- * @class Command
3165
+ * Undoable command with paired execute and undo operations.
3166
+ *
3167
+ * @private
2698
3168
  */
2699
3169
  class Command {
2700
3170
  /**
2701
- * @param {Function} execute The function that performs the action.
2702
- * @param {Function} undo The function that reverts the action.
3171
+ * @param {HistoryTaskCallback} execute - Function that performs the action.
3172
+ * @param {HistoryTaskCallback} undo - Function that reverts the action.
2703
3173
  */
2704
3174
  constructor(execute, undo) {
2705
3175
  /**
2706
3176
  * Executes the command.
2707
- * @type {Function}
3177
+ * @type {HistoryTaskCallback}
2708
3178
  */
2709
3179
  this.execute = execute;
2710
3180
  /**
2711
3181
  * Undoes the command.
2712
- * @type {Function}
3182
+ * @type {HistoryTaskCallback}
2713
3183
  */
2714
3184
  this.undo = undo;
2715
3185
  }
2716
3186
  }
2717
3187
 
2718
3188
  /**
2719
- * Manages a history of Command objects enabling undo/redo functionality.
2720
- * @class HistoryManager
3189
+ * Manages undo/redo history and serializes asynchronous history operations.
3190
+ *
3191
+ * @private
2721
3192
  */
2722
3193
  class HistoryManager {
2723
3194
  /**
2724
- * @param {number} [maxSize=50] Maximum number of commands to keep in history.
3195
+ * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
2725
3196
  */
2726
3197
  constructor(maxSize = 50) {
3198
+ /** @type {Array<Command>} */
2727
3199
  this.history = [];
3200
+ /** @type {number} */
2728
3201
  this.currentIndex = -1;
3202
+ /** @type {number} */
2729
3203
  this.maxSize = maxSize;
3204
+ /** @type {Promise<void>} */
2730
3205
  this.pending = Promise.resolve();
2731
3206
  }
2732
3207
 
3208
+ /**
3209
+ * Queues a history task after the previously queued undo/redo task completes.
3210
+ *
3211
+ * @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
3212
+ * @returns {Promise<void>} Resolves or rejects with the queued task result.
3213
+ * @private
3214
+ */
2733
3215
  enqueue(task) {
2734
- const run = this.pending.then(task, task);
2735
- this.pending = run.catch(() => {});
2736
- return run;
3216
+ const nextTask = this.pending.then(task, task);
3217
+ let pendingAfterTask;
3218
+ const resetPending = () => {
3219
+ if (this.pending === pendingAfterTask) {
3220
+ this.pending = Promise.resolve();
3221
+ }
3222
+ };
3223
+
3224
+ pendingAfterTask = nextTask.then(resetPending, resetPending);
3225
+ this.pending = pendingAfterTask;
3226
+ return nextTask;
2737
3227
  }
2738
3228
 
2739
3229
  /**
@@ -2744,7 +3234,6 @@ function ensureFabric() {
2744
3234
  * @returns {void}
2745
3235
  */
2746
3236
  execute(command) {
2747
- // Perform the command.
2748
3237
  command.execute();
2749
3238
  this.push(command);
2750
3239
  }
@@ -2757,17 +3246,15 @@ function ensureFabric() {
2757
3246
  * @returns {void}
2758
3247
  */
2759
3248
  push(command) {
2760
- // Remove any commands that are ahead of the current index.
3249
+ // Discard redo commands when a new branch is created.
2761
3250
  if (this.currentIndex < this.history.length - 1) {
2762
3251
  this.history = this.history.slice(0, this.currentIndex + 1);
2763
3252
  }
2764
3253
 
2765
- // Add the new command.
2766
3254
  this.history.push(command);
2767
3255
 
2768
- // Maintain the max size of the buffer.
2769
3256
  if (this.history.length > this.maxSize) {
2770
- this.history.shift(); // Remove the oldest command.
3257
+ this.history.shift();
2771
3258
  } else {
2772
3259
  this.currentIndex++;
2773
3260
  }
@@ -2794,7 +3281,7 @@ function ensureFabric() {
2794
3281
  /**
2795
3282
  * Undoes the last executed command if possible.
2796
3283
  *
2797
- * @returns {void}
3284
+ * @returns {Promise<void>} Resolves after the undo task completes.
2798
3285
  */
2799
3286
  undo() {
2800
3287
  return this.enqueue(async () => {
@@ -2809,7 +3296,7 @@ function ensureFabric() {
2809
3296
  /**
2810
3297
  * Redoes the next command in history if possible.
2811
3298
  *
2812
- * @returns {void}
3299
+ * @returns {Promise<void>} Resolves after the redo task completes.
2813
3300
  */
2814
3301
  redo() {
2815
3302
  return this.enqueue(async () => {