@bensitu/image-editor 1.4.2 → 1.5.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.4.2
4
+ * @version 1.5.1
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -132,6 +132,7 @@ function ensureFabric() {
132
132
  * @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
133
133
  * @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
134
134
  * @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
135
+ * @param {number} [options.maxExportPixels=50000000] - Maximum output pixels allowed per export.
135
136
  * @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
136
137
  * @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
137
138
  * @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
@@ -202,6 +203,7 @@ function ensureFabric() {
202
203
  imageLoadTimeoutMs: 30000,
203
204
 
204
205
  exportMultiplier: 1,
206
+ maxExportPixels: 50000000,
205
207
  exportImageAreaByDefault: true,
206
208
 
207
209
  defaultMaskWidth: 50,
@@ -283,8 +285,10 @@ function ensureFabric() {
283
285
  this._activeAnimationRejectors = new Set();
284
286
  this._disposed = false;
285
287
  this._initialized = false;
288
+ this._deprecatedElementKeyWarnings = new Set();
289
+ this._cropRotationWarningEmitted = false;
286
290
 
287
- this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
291
+ this.onImageLoaded = typeof this.options.onImageLoaded === 'function' ? this.options.onImageLoaded : null;
288
292
 
289
293
  this.animationQueue = new AnimationQueue();
290
294
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -338,10 +342,12 @@ function ensureFabric() {
338
342
  * Use this method to set up the editor UI before interacting with it.
339
343
  *
340
344
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
341
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
342
- * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
343
- * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
344
- * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
345
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
346
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
347
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
348
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
349
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
350
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
345
351
  *
346
352
  * @returns {void}
347
353
  *
@@ -350,11 +356,17 @@ function ensureFabric() {
350
356
  * @example
351
357
  * editor.init({
352
358
  * canvas: 'myFabricCanvasId',
353
- * downloadBtn: 'myDownloadButtonId'
359
+ * downloadImageButton: 'myDownloadButtonId'
354
360
  * });
355
361
  */
356
362
  init(idMap = {}) {
357
- if (!this._fabricLoaded) return;
363
+ if (!this._fabricLoaded) {
364
+ this._fabricLoaded = !!ensureFabric();
365
+ if (!this._fabricLoaded) {
366
+ this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
367
+ return;
368
+ }
369
+ }
358
370
  if (this._initialized || this.canvas) this.dispose();
359
371
  this._disposed = false;
360
372
  this._initialized = true;
@@ -373,30 +385,50 @@ function ensureFabric() {
373
385
  const defaults = {
374
386
  canvas: 'fabricCanvas',
375
387
  canvasContainer: null, // Pass an ID here if you have a scrollable viewport container
376
- imgPlaceholder: 'imgPlaceholder',
377
- scaleRate: 'scaleRate',
378
- rotationLeftInput: 'rotationLeftInput',
379
- rotationRightInput: 'rotationRightInput',
380
- rotateLeftBtn: 'rotateLeftBtn',
381
- rotateRightBtn: 'rotateRightBtn',
382
- addMaskBtn: 'addMaskBtn',
383
- removeMaskBtn: 'removeMaskBtn',
384
- removeAllMasksBtn: 'removeAllMasksBtn',
385
- mergeBtn: 'mergeBtn',
386
- downloadBtn: 'downloadBtn',
388
+ imagePlaceholder: 'imagePlaceholder',
389
+ imgPlaceholder: null,
390
+ scalePercentageInput: 'scalePercentageInput',
391
+ scaleRate: null,
392
+ rotateLeftDegreesInput: 'rotateLeftDegreesInput',
393
+ rotationLeftInput: null,
394
+ rotateRightDegreesInput: 'rotateRightDegreesInput',
395
+ rotationRightInput: null,
396
+ rotateLeftButton: 'rotateLeftButton',
397
+ rotateLeftBtn: null,
398
+ rotateRightButton: 'rotateRightButton',
399
+ rotateRightBtn: null,
400
+ createMaskButton: 'createMaskButton',
401
+ addMaskBtn: null,
402
+ removeSelectedMaskButton: 'removeSelectedMaskButton',
403
+ removeMaskBtn: null,
404
+ removeAllMasksButton: 'removeAllMasksButton',
405
+ removeAllMasksBtn: null,
406
+ mergeMasksButton: 'mergeMasksButton',
407
+ mergeBtn: null,
408
+ downloadImageButton: 'downloadImageButton',
409
+ downloadBtn: null,
387
410
  maskList: 'maskList',
388
- zoomInBtn: 'zoomInBtn',
389
- zoomOutBtn: 'zoomOutBtn',
390
- resetBtn: 'resetBtn',
391
- undoBtn: 'undoBtn',
392
- redoBtn: 'redoBtn',
411
+ zoomInButton: 'zoomInButton',
412
+ zoomInBtn: null,
413
+ zoomOutButton: 'zoomOutButton',
414
+ zoomOutBtn: null,
415
+ resetImageTransformButton: 'resetImageTransformButton',
416
+ resetBtn: null,
417
+ undoButton: 'undoButton',
418
+ undoBtn: null,
419
+ redoButton: 'redoButton',
420
+ redoBtn: null,
393
421
  imageInput: 'imageInput',
394
- cropBtn: 'cropBtn',
395
- applyCropBtn: 'applyCropBtn',
396
- cancelCropBtn: 'cancelCropBtn'
422
+ uploadArea: null,
423
+ enterCropModeButton: 'enterCropModeButton',
424
+ cropBtn: null,
425
+ applyCropButton: 'applyCropButton',
426
+ applyCropBtn: null,
427
+ cancelCropButton: 'cancelCropButton',
428
+ cancelCropBtn: null
397
429
  };
398
430
 
399
- this.elements = { ...defaults, ...idMap };
431
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
400
432
  this._elementCache = {};
401
433
 
402
434
  this._initCanvas();
@@ -407,12 +439,80 @@ function ensureFabric() {
407
439
 
408
440
  // Auto-load initial image if provided
409
441
  if (this.options.initialImageBase64) {
410
- this.loadImage(this.options.initialImageBase64);
442
+ this.loadImage(this.options.initialImageBase64)
443
+ .catch(error => this._reportError('initialImageBase64 could not be loaded', error));
411
444
  } else {
412
445
  this._updatePlaceholderStatus();
413
446
  }
414
447
  }
415
448
 
449
+ _resolveElementIdMap(idMap, defaults) {
450
+ const resolved = { ...defaults, ...idMap };
451
+
452
+ this._resolveElementAliases(resolved, idMap, defaults, 'imagePlaceholder', ['imgPlaceholder']);
453
+ this._resolveElementAliases(resolved, idMap, defaults, 'scalePercentageInput', ['scaleRate']);
454
+ this._resolveElementAliases(resolved, idMap, defaults, 'rotateLeftDegreesInput', ['rotationLeftInput']);
455
+ this._resolveElementAliases(resolved, idMap, defaults, 'rotateRightDegreesInput', ['rotationRightInput']);
456
+ this._resolveElementAlias(resolved, idMap, defaults, 'rotateLeftButton', 'rotateLeftBtn');
457
+ this._resolveElementAlias(resolved, idMap, defaults, 'rotateRightButton', 'rotateRightBtn');
458
+ this._resolveElementAlias(resolved, idMap, defaults, 'createMaskButton', 'addMaskBtn');
459
+ this._resolveElementAliases(resolved, idMap, defaults, 'removeSelectedMaskButton', ['removeMaskBtn']);
460
+ this._resolveElementAlias(resolved, idMap, defaults, 'removeAllMasksButton', 'removeAllMasksBtn');
461
+ this._resolveElementAlias(resolved, idMap, defaults, 'mergeMasksButton', 'mergeBtn');
462
+ this._resolveElementAliases(resolved, idMap, defaults, 'downloadImageButton', ['downloadBtn']);
463
+ this._resolveElementAlias(resolved, idMap, defaults, 'zoomInButton', 'zoomInBtn');
464
+ this._resolveElementAlias(resolved, idMap, defaults, 'zoomOutButton', 'zoomOutBtn');
465
+ this._resolveElementAlias(resolved, idMap, defaults, 'resetImageTransformButton', 'resetBtn');
466
+ this._resolveElementAlias(resolved, idMap, defaults, 'undoButton', 'undoBtn');
467
+ this._resolveElementAlias(resolved, idMap, defaults, 'redoButton', 'redoBtn');
468
+ this._resolveElementAliases(resolved, idMap, defaults, 'enterCropModeButton', ['cropBtn']);
469
+ this._resolveElementAlias(resolved, idMap, defaults, 'applyCropButton', 'applyCropBtn');
470
+ this._resolveElementAlias(resolved, idMap, defaults, 'cancelCropButton', 'cancelCropBtn');
471
+
472
+ return resolved;
473
+ }
474
+
475
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
476
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
477
+ }
478
+
479
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
480
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
481
+
482
+ if (hasCanonicalKey) {
483
+ resolved[canonicalKey] = idMap[canonicalKey];
484
+ return;
485
+ }
486
+
487
+ let deprecatedValue;
488
+ let hasDeprecatedValue = false;
489
+ for (const deprecatedKey of deprecatedKeys) {
490
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
491
+ if (!hasDeprecatedValue) {
492
+ deprecatedValue = idMap[deprecatedKey];
493
+ hasDeprecatedValue = true;
494
+ }
495
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
496
+ }
497
+ }
498
+
499
+ if (hasDeprecatedValue) {
500
+ resolved[canonicalKey] = deprecatedValue;
501
+ return;
502
+ }
503
+
504
+ resolved[canonicalKey] = defaults[canonicalKey];
505
+ }
506
+
507
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
508
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = new Set();
509
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
510
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
511
+ this._reportWarning(
512
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
513
+ );
514
+ }
515
+
416
516
  _reportError(message, error = null) {
417
517
  const handler = this.options && this.options.onError;
418
518
  if (typeof handler !== 'function') return;
@@ -435,6 +535,12 @@ function ensureFabric() {
435
535
  }
436
536
  }
437
537
 
538
+ _notifyImageLoaded() {
539
+ const optionsCallback = this.options && this.options.onImageLoaded;
540
+ const callback = typeof optionsCallback === 'function' ? optionsCallback : this.onImageLoaded;
541
+ if (typeof callback === 'function') callback();
542
+ }
543
+
438
544
  /**
439
545
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
440
546
  *
@@ -460,7 +566,7 @@ function ensureFabric() {
460
566
  this.containerElement = canvasElement.parentElement;
461
567
  }
462
568
 
463
- this.placeholderElement = this._getElement('imgPlaceholder') || null;
569
+ this.placeholderElement = this._getElement('imagePlaceholder') || null;
464
570
 
465
571
  // Prefer a measured container size when it is available.
466
572
  let initialWidth = this.options.canvasWidth;
@@ -565,13 +671,14 @@ function ensureFabric() {
565
671
  this._captureContainerOverflowState();
566
672
 
567
673
  const shouldPreserveScroll = options.preserveScroll === true;
568
- if (this.options.coverImageToCanvas) {
674
+ const layoutMode = this._getImageLayoutMode();
675
+ if (layoutMode === 'cover') {
569
676
  this.containerElement.style.overflow = 'scroll';
570
677
  if (!shouldPreserveScroll) {
571
678
  this.containerElement.scrollLeft = 0;
572
679
  this.containerElement.scrollTop = 0;
573
680
  }
574
- } else if (this.options.fitImageToCanvas) {
681
+ } else if (layoutMode === 'fit') {
575
682
  this.containerElement.style.overflow = 'auto';
576
683
  if (!shouldPreserveScroll) {
577
684
  this.containerElement.scrollLeft = 0;
@@ -628,23 +735,23 @@ function ensureFabric() {
628
735
  }
629
736
  });
630
737
  // Zoom & reset
631
- this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
632
- this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
633
- this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
738
+ this._bindIfExists('zoomInButton', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
739
+ this._bindIfExists('zoomOutButton', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
740
+ this._bindIfExists('resetImageTransformButton', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
634
741
  // Mask management
635
- this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
636
- this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
637
- this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
742
+ this._bindIfExists('createMaskButton', 'click', () => this.createMask());
743
+ this._bindIfExists('removeSelectedMaskButton', 'click', () => this.removeSelectedMask());
744
+ this._bindIfExists('removeAllMasksButton', 'click', () => this.removeAllMasks());
638
745
  // Merge + download
639
- this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
640
- this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
746
+ this._bindIfExists('mergeMasksButton', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
747
+ this._bindIfExists('downloadImageButton', 'click', () => this.downloadImage());
641
748
  // Undo + Redo
642
- this._bindIfExists('undoBtn', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
643
- this._bindIfExists('redoBtn', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
749
+ this._bindIfExists('undoButton', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
750
+ this._bindIfExists('redoButton', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
644
751
 
645
752
  // Rotation buttons (step can be overridden by two input fields)
646
- this._bindIfExists('rotateLeftBtn', 'click', () => {
647
- const rotationInputElement = this._getElement('rotationLeftInput');
753
+ this._bindIfExists('rotateLeftButton', 'click', () => {
754
+ const rotationInputElement = this._getElement('rotateLeftDegreesInput');
648
755
  let step = this.options.rotationStep;
649
756
  if (rotationInputElement) {
650
757
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -652,8 +759,8 @@ function ensureFabric() {
652
759
  }
653
760
  this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
654
761
  });
655
- this._bindIfExists('rotateRightBtn', 'click', () => {
656
- const rotationInputElement = this._getElement('rotationRightInput');
762
+ this._bindIfExists('rotateRightButton', 'click', () => {
763
+ const rotationInputElement = this._getElement('rotateRightDegreesInput');
657
764
  let step = this.options.rotationStep;
658
765
  if (rotationInputElement) {
659
766
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -663,9 +770,9 @@ function ensureFabric() {
663
770
  });
664
771
 
665
772
  // Crop bindings (optional: bound only if element IDs exist in elements)
666
- this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
667
- this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
668
- this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
773
+ this._bindIfExists('enterCropModeButton', 'click', () => this.enterCropMode());
774
+ this._bindIfExists('applyCropButton', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
775
+ this._bindIfExists('cancelCropButton', 'click', () => this.cancelCrop());
669
776
  this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
670
777
  }
671
778
 
@@ -743,6 +850,13 @@ function ensureFabric() {
743
850
  );
744
851
  }
745
852
 
853
+ _getImageLayoutMode() {
854
+ if (this.options.fitImageToCanvas) return 'fit';
855
+ if (this.options.coverImageToCanvas) return 'cover';
856
+ if (this.options.expandCanvasToImage) return 'expand';
857
+ return 'contain';
858
+ }
859
+
746
860
  /**
747
861
  * Loads a base64 data URL into the Fabric canvas as the base image.
748
862
  *
@@ -756,14 +870,21 @@ function ensureFabric() {
756
870
  if (!this._fabricLoaded) return;
757
871
  if (!this.canvas || this._disposed) return;
758
872
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
873
+ options = options || {};
759
874
  this._assertIdleForOperation('loadImage', options);
760
875
 
761
- this._isLoading = true;
762
- this._updateUI();
763
- this._warnOnImageLayoutOptionConflict();
764
- const transaction = this._captureLoadImageTransaction();
876
+ const isNestedOperation = this._isOwnInternalOperation(options);
877
+ const operationToken = isNestedOperation
878
+ ? this._getInternalOperationToken(options)
879
+ : this._beginBusyOperation('loadImage');
880
+ let transaction = null;
765
881
 
766
882
  try {
883
+ this._isLoading = true;
884
+ this._updateUI();
885
+ this._warnOnImageLayoutOptionConflict();
886
+ transaction = this._captureLoadImageTransaction();
887
+
767
888
  const imageElement = await this._createImageElement(imageBase64);
768
889
  if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
769
890
 
@@ -811,8 +932,9 @@ function ensureFabric() {
811
932
  const viewport = this._getContainerViewportSize();
812
933
  const minWidth = viewport.width;
813
934
  const minHeight = viewport.height;
935
+ const layoutMode = this._getImageLayoutMode();
814
936
 
815
- if (this.options.fitImageToCanvas) {
937
+ if (layoutMode === 'fit') {
816
938
  const canvasWidth = Math.max(1, minWidth - 1);
817
939
  const canvasHeight = Math.max(1, minHeight - 1);
818
940
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -820,13 +942,13 @@ function ensureFabric() {
820
942
  fabricImage.set({ left: 0, top: 0 });
821
943
  fabricImage.scale(fitScale);
822
944
  this.baseImageScale = fabricImage.scaleX || 1;
823
- } else if (this.options.coverImageToCanvas) {
945
+ } else if (layoutMode === 'cover') {
824
946
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
825
947
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
826
948
  fabricImage.set({ left: 0, top: 0 });
827
949
  fabricImage.scale(layout.scale);
828
950
  this.baseImageScale = fabricImage.scaleX || 1;
829
- } else if (this.options.expandCanvasToImage) {
951
+ } else if (layoutMode === 'expand') {
830
952
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
831
953
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
832
954
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -860,14 +982,16 @@ function ensureFabric() {
860
982
  this.canvas.renderAll();
861
983
  this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
862
984
 
863
- if (typeof this.onImageLoaded === 'function') {
864
- this.onImageLoaded();
865
- }
985
+ this._notifyImageLoaded();
866
986
  } catch (error) {
867
- await this._rollbackLoadImageTransaction(transaction);
987
+ await this._rollbackLoadImageTransaction(
988
+ transaction,
989
+ this._withInternalOperationOptions(operationToken)
990
+ );
868
991
  throw error;
869
992
  } finally {
870
993
  this._isLoading = false;
994
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
871
995
  if (!this._disposed && this.canvas) this._updateUI();
872
996
  }
873
997
  }
@@ -929,7 +1053,7 @@ function ensureFabric() {
929
1053
  };
930
1054
  timerId = setTimeout(() => {
931
1055
  settle(() => reject(new Error('Image load timed out')));
932
- try { imageElement.src = ''; } catch (error) { void error; }
1056
+ try { imageElement.src = ''; } catch (error) { this._reportWarning('Image timeout cleanup failed', error); }
933
1057
  }, safeTimeoutMs);
934
1058
  imageElement.onload = () => settle(() => resolve(imageElement));
935
1059
  imageElement.onerror = (error) => settle(() => reject(error));
@@ -999,33 +1123,39 @@ function ensureFabric() {
999
1123
  };
1000
1124
  }
1001
1125
 
1002
- async _rollbackLoadImageTransaction(transaction) {
1126
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
1003
1127
  if (!transaction || !this.canvas || this._disposed) return;
1004
1128
  let didRestoreCanvasState = false;
1129
+ let didFailCanvasRestore = false;
1005
1130
  try {
1006
1131
  if (transaction.canvasState) {
1007
- await this.loadFromState(transaction.canvasState);
1132
+ await this.loadFromState(transaction.canvasState, options);
1008
1133
  didRestoreCanvasState = true;
1009
1134
  }
1010
1135
  } catch (error) {
1011
1136
  this._lastMask = null;
1137
+ didFailCanvasRestore = true;
1012
1138
  this._reportError('loadImage rollback failed', error);
1013
1139
  }
1014
1140
 
1015
- this.baseImageScale = transaction.baseImageScale;
1016
- this.currentScale = transaction.currentScale;
1017
- this.currentRotation = transaction.currentRotation;
1018
- this.maskCounter = transaction.maskCounter;
1019
- this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
1020
- this._lastSnapshot = transaction.lastSnapshot;
1021
- if (didRestoreCanvasState) {
1022
- this._restoreLastMaskReference(transaction.lastMask);
1141
+ if (didFailCanvasRestore) {
1142
+ this._reconcileEditorStateFromCanvas();
1023
1143
  } else {
1024
- this._lastMask = null;
1144
+ this.baseImageScale = transaction.baseImageScale;
1145
+ this.currentScale = transaction.currentScale;
1146
+ this.currentRotation = transaction.currentRotation;
1147
+ this.maskCounter = transaction.maskCounter;
1148
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
1149
+ this._lastSnapshot = transaction.lastSnapshot;
1150
+ if (didRestoreCanvasState) {
1151
+ this._restoreLastMaskReference(transaction.lastMask);
1152
+ } else {
1153
+ this._lastMask = null;
1154
+ }
1155
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1156
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1157
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1025
1158
  }
1026
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1027
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1028
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1029
1159
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
1030
1160
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
1031
1161
  if (this.containerElement) {
@@ -1039,6 +1169,49 @@ function ensureFabric() {
1039
1169
  if (this.canvas) this.canvas.renderAll();
1040
1170
  }
1041
1171
 
1172
+ _reconcileEditorStateFromCanvas() {
1173
+ if (!this.canvas) {
1174
+ this.originalImage = null;
1175
+ this.baseImageScale = 1;
1176
+ this.currentScale = 1;
1177
+ this.currentRotation = 0;
1178
+ this.maskCounter = 0;
1179
+ this.isImageLoadedToCanvas = false;
1180
+ this._lastSnapshot = null;
1181
+ this._clearMaskPlacementMemory();
1182
+ return;
1183
+ }
1184
+
1185
+ const canvasObjects = this.canvas.getObjects();
1186
+ this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
1187
+ if (this.originalImage) {
1188
+ const imageScale = Number(this.originalImage.scaleX) || 1;
1189
+ this.baseImageScale = imageScale;
1190
+ this.currentScale = 1;
1191
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1192
+ } else {
1193
+ this.baseImageScale = 1;
1194
+ this.currentScale = 1;
1195
+ this.currentRotation = 0;
1196
+ }
1197
+
1198
+ const masks = canvasObjects.filter(object => object.maskId);
1199
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
1200
+ this._lastMask = masks[masks.length - 1] || null;
1201
+ if (!this._lastMask) {
1202
+ this._lastMaskInitialLeft = null;
1203
+ this._lastMaskInitialTop = null;
1204
+ this._lastMaskInitialWidth = null;
1205
+ }
1206
+ this.isImageLoadedToCanvas = !!this.originalImage;
1207
+ try {
1208
+ this._lastSnapshot = this._serializeCanvasState();
1209
+ } catch (error) {
1210
+ this._lastSnapshot = null;
1211
+ this._reportWarning('loadImage rollback: failed to reconcile canvas snapshot', error);
1212
+ }
1213
+ }
1214
+
1042
1215
  _restoreLastMaskReference(previousLastMask) {
1043
1216
  if (!this.canvas) {
1044
1217
  this._lastMask = null;
@@ -1116,6 +1289,7 @@ function ensureFabric() {
1116
1289
  * @private
1117
1290
  */
1118
1291
  _setCanvasSizeInt(width, height) {
1292
+ if (!this.canvas) return;
1119
1293
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
1120
1294
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
1121
1295
  // Set fabric internal and also style attributes to keep DOM consistent
@@ -1270,10 +1444,18 @@ function ensureFabric() {
1270
1444
 
1271
1445
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1272
1446
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1447
+ const safetyMargin = this._getScrollSafetyMargin();
1448
+ const layoutMode = this._getImageLayoutMode();
1449
+ const shouldReserveNoScrollbarMargin = layoutMode === 'fit' || layoutMode === 'cover';
1450
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1451
+ const margin = hasOppositeScrollbar ? safetyMargin : (shouldReserveNoScrollbarMargin ? 1 : 0);
1452
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1453
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1454
+ };
1273
1455
 
1274
1456
  return {
1275
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1276
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1457
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1458
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1277
1459
  viewportWidth: effectiveWidth,
1278
1460
  viewportHeight: effectiveHeight,
1279
1461
  hasHorizontal,
@@ -1406,6 +1588,50 @@ function ensureFabric() {
1406
1588
  }
1407
1589
  }
1408
1590
 
1591
+ _getSerializableStateObjects() {
1592
+ if (!this.canvas) return [];
1593
+ return this.canvas.getObjects().filter(object => !object.isCropRect && !object.maskLabel);
1594
+ }
1595
+
1596
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1597
+ if (!Array.isArray(serializedObjects)) return;
1598
+ const fabricObjects = this._getSerializableStateObjects();
1599
+ const numericProperties = [
1600
+ 'left',
1601
+ 'top',
1602
+ 'width',
1603
+ 'height',
1604
+ 'scaleX',
1605
+ 'scaleY',
1606
+ 'angle',
1607
+ 'skewX',
1608
+ 'skewY',
1609
+ 'cropX',
1610
+ 'cropY',
1611
+ 'radius',
1612
+ 'rx',
1613
+ 'ry',
1614
+ 'strokeWidth'
1615
+ ];
1616
+
1617
+ serializedObjects.forEach((serializedObject, index) => {
1618
+ const fabricObject = fabricObjects[index];
1619
+ if (!serializedObject || !fabricObject) return;
1620
+
1621
+ numericProperties.forEach(property => {
1622
+ const numericValue = Number(fabricObject[property]);
1623
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1624
+ });
1625
+
1626
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1627
+ serializedObject.points = fabricObject.points.map(point => ({
1628
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1629
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1630
+ }));
1631
+ }
1632
+ });
1633
+ }
1634
+
1409
1635
  _restoreMaskControls(mask) {
1410
1636
  if (!mask) return;
1411
1637
 
@@ -1427,7 +1653,7 @@ function ensureFabric() {
1427
1653
  /**
1428
1654
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1429
1655
  *
1430
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1656
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1431
1657
  * @private
1432
1658
  */
1433
1659
  _serializeEditorMetadata() {
@@ -1435,13 +1661,17 @@ function ensureFabric() {
1435
1661
  const currentScale = Number(this.currentScale);
1436
1662
  const currentRotation = Number(this.currentRotation);
1437
1663
  const maskCounter = Number(this.maskCounter);
1664
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1665
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1438
1666
 
1439
1667
  return {
1440
1668
  version: 1,
1441
1669
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1442
1670
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1443
1671
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1444
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1672
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1673
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1674
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1445
1675
  };
1446
1676
  }
1447
1677
 
@@ -1451,6 +1681,7 @@ function ensureFabric() {
1451
1681
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1452
1682
  if (Array.isArray(jsonObject.objects)) {
1453
1683
  jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
1684
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1454
1685
  }
1455
1686
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1456
1687
  return JSON.stringify(jsonObject);
@@ -1533,6 +1764,13 @@ function ensureFabric() {
1533
1764
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1534
1765
  }
1535
1766
 
1767
+ _hasScaledImageEdge(axis) {
1768
+ if (!this.originalImage) return false;
1769
+ const scale = Number(axis === 'y' ? this.originalImage.scaleY : this.originalImage.scaleX);
1770
+ if (!Number.isFinite(scale)) return false;
1771
+ return Math.abs(scale - 1) > 0.01;
1772
+ }
1773
+
1536
1774
  _getPartialExportEdges(bounds) {
1537
1775
  if (!bounds) return null;
1538
1776
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1542,8 +1780,8 @@ function ensureFabric() {
1542
1780
  return {
1543
1781
  left: this._hasFractionalCanvasEdge(bounds.left),
1544
1782
  top: this._hasFractionalCanvasEdge(bounds.top),
1545
- right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1546
- bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1783
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge('x'),
1784
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge('y')
1547
1785
  };
1548
1786
  }
1549
1787
 
@@ -1609,7 +1847,8 @@ function ensureFabric() {
1609
1847
  * @private
1610
1848
  */
1611
1849
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
1612
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1850
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1851
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1613
1852
  const safeFormat = this._normalizeImageFormat(format);
1614
1853
  const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1615
1854
  let regionDataUrl = this.canvas.toDataURL({
@@ -1627,6 +1866,30 @@ function ensureFabric() {
1627
1866
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1628
1867
  }
1629
1868
 
1869
+ _getSafeExportMultiplier(multiplier) {
1870
+ const numericMultiplier = Number(multiplier);
1871
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1872
+ throw new Error('Export multiplier must be a finite positive number');
1873
+ }
1874
+ return Math.max(1, numericMultiplier);
1875
+ }
1876
+
1877
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1878
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1879
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1880
+ const outputWidth = Math.ceil(width * safeMultiplier);
1881
+ const outputHeight = Math.ceil(height * safeMultiplier);
1882
+ const outputPixels = outputWidth * outputHeight;
1883
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1884
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0
1885
+ ? Math.floor(configuredMaxPixels)
1886
+ : 50000000;
1887
+
1888
+ if (outputPixels > maxPixels) {
1889
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1890
+ }
1891
+ }
1892
+
1630
1893
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1631
1894
  const imageElement = await this._createImageElement(dataUrl);
1632
1895
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1679,6 +1942,7 @@ function ensureFabric() {
1679
1942
 
1680
1943
  _decodeBase64Payload(base64Payload) {
1681
1944
  const payload = String(base64Payload || '');
1945
+ if (!payload) throw new Error('Data URL base64 payload is empty');
1682
1946
  if (typeof atob === 'function') {
1683
1947
  return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
1684
1948
  }
@@ -1688,6 +1952,14 @@ function ensureFabric() {
1688
1952
  throw new Error('Base64 decoding is unavailable');
1689
1953
  }
1690
1954
 
1955
+ _decodeDataUrlPayload(dataUrl) {
1956
+ const match = String(dataUrl || '').match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
1957
+ if (!match || !match[2]) {
1958
+ throw new Error('Export produced an invalid or empty base64 data URL');
1959
+ }
1960
+ return this._decodeBase64Payload(match[2]);
1961
+ }
1962
+
1691
1963
  /**
1692
1964
  * Gets the top-left corner coordinates of the given object.
1693
1965
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1806,26 +2078,57 @@ function ensureFabric() {
1806
2078
  const currentHeight = this.canvas.getHeight();
1807
2079
  let requiredWidth = currentWidth;
1808
2080
  let requiredHeight = currentHeight;
1809
- fabricObjects.forEach(fabricObject => {
2081
+ const layoutMode = this._getImageLayoutMode();
2082
+ const usesScrollableFitBounds = layoutMode === 'fit' || layoutMode === 'cover';
2083
+ let contentWidth = 0;
2084
+ let contentHeight = 0;
2085
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1810
2086
  if (!fabricObject) return;
1811
2087
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1812
2088
  const boundingRect = fabricObject.getBoundingRect(true, true);
1813
- requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1814
- requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
2089
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
2090
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
2091
+ contentWidth = Math.max(contentWidth, right);
2092
+ contentHeight = Math.max(contentHeight, bottom);
2093
+ return { right, bottom };
2094
+ };
2095
+ fabricObjects.forEach(fabricObject => {
2096
+ const bounds = includeObjectBounds(fabricObject, padding);
2097
+ if (!bounds) return;
2098
+ requiredWidth = Math.max(requiredWidth, bounds.right);
2099
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1815
2100
  });
1816
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
2101
+ if (usesScrollableFitBounds) {
2102
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
2103
+ this.canvas.getObjects().forEach(object => {
2104
+ if (object && object.maskId) includeObjectBounds(object, padding);
2105
+ });
2106
+
2107
+ const contentSize = this._getScrollableCanvasSize(
2108
+ Math.max(1, contentWidth),
2109
+ Math.max(1, contentHeight)
2110
+ );
1817
2111
 
2112
+ const newWidth = contentSize.hasHorizontal
2113
+ ? Math.max(currentWidth, contentSize.width)
2114
+ : contentSize.width;
2115
+ const newHeight = contentSize.hasVertical
2116
+ ? Math.max(currentHeight, contentSize.height)
2117
+ : contentSize.height;
2118
+
2119
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
2120
+ this._setCanvasSizeInt(newWidth, newHeight);
2121
+ }
2122
+ return;
2123
+ }
1818
2124
  let minWidth = 0;
1819
2125
  let minHeight = 0;
1820
- if (shouldUseScrollSafeViewport) {
2126
+ if (this.containerElement) {
1821
2127
  const viewport = this._getContainerViewportSize();
1822
2128
  const safetyMargin = this._getScrollSafetyMargin();
1823
2129
 
1824
2130
  minWidth = Math.max(1, viewport.width - safetyMargin);
1825
2131
  minHeight = Math.max(1, viewport.height - safetyMargin);
1826
- } else if (this.containerElement) {
1827
- minWidth = Math.floor(this.containerElement.clientWidth || 0);
1828
- minHeight = Math.floor(this.containerElement.clientHeight || 0);
1829
2132
  }
1830
2133
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1831
2134
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1837,16 +2140,65 @@ function ensureFabric() {
1837
2140
  }
1838
2141
  }
1839
2142
 
1840
- /**
1841
- * Expands the canvas so one object remains visible after an edit.
1842
- *
1843
- * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1844
- * @param {number} [padding=10] - Extra canvas space after the object edge.
1845
- * @returns {void}
1846
- * @private
1847
- */
1848
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1849
- this._expandCanvasToFitObjects([fabricObject], padding);
2143
+ _captureImageDisplayBounds() {
2144
+ if (!this.originalImage || !this.canvas) return null;
2145
+ this.originalImage.setCoords();
2146
+ const bounds = this.originalImage.getBoundingRect(true, true);
2147
+ const width = Number(bounds && bounds.width);
2148
+ const height = Number(bounds && bounds.height);
2149
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
2150
+
2151
+ return {
2152
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
2153
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
2154
+ width,
2155
+ height
2156
+ };
2157
+ }
2158
+
2159
+ _restoreImageDisplayBounds(displayBounds) {
2160
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
2161
+ const imageWidth = Number(this.originalImage.width);
2162
+ const imageHeight = Number(this.originalImage.height);
2163
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
2164
+
2165
+ const scaleX = Number(displayBounds.width) / imageWidth;
2166
+ const scaleY = Number(displayBounds.height) / imageHeight;
2167
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
2168
+
2169
+ const left = Number(displayBounds.left) || 0;
2170
+ const top = Number(displayBounds.top) || 0;
2171
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
2172
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
2173
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
2174
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
2175
+ const layoutMode = this._getImageLayoutMode();
2176
+ if (layoutMode === 'fit' || layoutMode === 'cover') {
2177
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
2178
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
2179
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
2180
+ }
2181
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
2182
+ this._setCanvasSizeInt(
2183
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
2184
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
2185
+ );
2186
+ }
2187
+
2188
+ this.originalImage.set({
2189
+ originX: 'left',
2190
+ originY: 'top',
2191
+ left,
2192
+ top,
2193
+ scaleX,
2194
+ scaleY
2195
+ });
2196
+ this.originalImage.setCoords();
2197
+ this.baseImageScale = scaleX;
2198
+ this.currentScale = 1;
2199
+ this.currentRotation = Number(this.originalImage.angle) || 0;
2200
+ this._updateInputs();
2201
+ this.canvas.renderAll();
1850
2202
  }
1851
2203
 
1852
2204
  /**
@@ -1862,7 +2214,14 @@ function ensureFabric() {
1862
2214
  } catch (error) {
1863
2215
  return Promise.reject(error);
1864
2216
  }
1865
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
2217
+ return this.animationQueue.add(async () => {
2218
+ const operationToken = this._beginBusyOperation('scaleImage');
2219
+ try {
2220
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
2221
+ } finally {
2222
+ this._endBusyOperation(operationToken);
2223
+ }
2224
+ })
1866
2225
  .finally(() => {
1867
2226
  if (!this._disposed && this.canvas) this._updateUI();
1868
2227
  });
@@ -1904,10 +2263,17 @@ function ensureFabric() {
1904
2263
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1905
2264
  }
1906
2265
 
2266
+ _isCropModeAllowedOperation(operationName) {
2267
+ return operationName === 'applyCrop' || operationName === 'cancelCrop';
2268
+ }
2269
+
1907
2270
  _assertIdleForOperation(operationName, options = {}) {
1908
2271
  this._assertEditorAvailable(operationName);
1909
2272
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
1910
- if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
2273
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2274
+ throw new Error(`${operationName} cannot run while crop mode is active`);
2275
+ }
2276
+ if ((this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) && !isOwnInternalOperation) {
1911
2277
  throw new Error(`${operationName} cannot run while an animation is running`);
1912
2278
  }
1913
2279
  if (this._isLoading && !isOwnInternalOperation) {
@@ -1920,10 +2286,14 @@ function ensureFabric() {
1920
2286
 
1921
2287
  _assertCanQueueAnimation(operationName, options = {}) {
1922
2288
  this._assertEditorAvailable(operationName);
1923
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
2289
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
2290
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2291
+ throw new Error(`${operationName} cannot run while crop mode is active`);
2292
+ }
2293
+ if (this._isLoading && !isOwnInternalOperation) {
1924
2294
  throw new Error(`${operationName} cannot run while an image is loading`);
1925
2295
  }
1926
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
2296
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1927
2297
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1928
2298
  }
1929
2299
  }
@@ -2026,7 +2396,7 @@ function ensureFabric() {
2026
2396
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2027
2397
 
2028
2398
  this._updateInputs();
2029
- if (saveHistory) this.saveState();
2399
+ if (saveHistory) this.saveState(options);
2030
2400
  } finally {
2031
2401
  if (didStartAnimation) {
2032
2402
  this.isAnimating = false;
@@ -2049,7 +2419,14 @@ function ensureFabric() {
2049
2419
  } catch (error) {
2050
2420
  return Promise.reject(error);
2051
2421
  }
2052
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
2422
+ return this.animationQueue.add(async () => {
2423
+ const operationToken = this._beginBusyOperation('rotateImage');
2424
+ try {
2425
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2426
+ } finally {
2427
+ this._endBusyOperation(operationToken);
2428
+ }
2429
+ })
2053
2430
  .finally(() => {
2054
2431
  if (!this._disposed && this.canvas) this._updateUI();
2055
2432
  });
@@ -2100,7 +2477,7 @@ function ensureFabric() {
2100
2477
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2101
2478
 
2102
2479
  this._updateInputs();
2103
- if (saveHistory) this.saveState();
2480
+ if (saveHistory) this.saveState(options);
2104
2481
  didCompleteRotation = true;
2105
2482
  } finally {
2106
2483
  if (!didCompleteRotation && !this._disposed && image) {
@@ -2129,11 +2506,23 @@ function ensureFabric() {
2129
2506
  }
2130
2507
 
2131
2508
  return this.animationQueue.add(async () => {
2509
+ const operationToken = this._beginBusyOperation('resetImageTransform');
2132
2510
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
2133
- await this._scaleImageImpl(1, { saveHistory: false });
2134
- await this._rotateImageImpl(0, { saveHistory: false });
2135
- const after = this._captureCanvasStateOrThrow('resetImageTransform');
2136
- this._pushStateTransition(before, after);
2511
+ try {
2512
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2513
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2514
+ const after = this._captureCanvasStateOrThrow('resetImageTransform');
2515
+ this._pushStateTransition(before, after);
2516
+ } catch (error) {
2517
+ try {
2518
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
2519
+ } catch (restoreError) {
2520
+ this._reportError('resetImageTransform rollback failed', restoreError);
2521
+ }
2522
+ throw error;
2523
+ } finally {
2524
+ this._endBusyOperation(operationToken);
2525
+ }
2137
2526
  }).finally(() => {
2138
2527
  if (!this._disposed && this.canvas) this._updateUI();
2139
2528
  }).catch(error => {
@@ -2159,8 +2548,13 @@ function ensureFabric() {
2159
2548
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
2160
2549
  * @public
2161
2550
  */
2162
- loadFromState(serializedState) {
2551
+ loadFromState(serializedState, options = {}) {
2163
2552
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2553
+ try {
2554
+ this._assertIdleForOperation('loadFromState', options);
2555
+ } catch (error) {
2556
+ return Promise.reject(error);
2557
+ }
2164
2558
  if (this._cropMode || this._cropRect) {
2165
2559
  this._removeCropRect();
2166
2560
  this._restoreCropObjectState();
@@ -2177,6 +2571,13 @@ function ensureFabric() {
2177
2571
  ? JSON.parse(serializedState)
2178
2572
  : serializedState;
2179
2573
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
2574
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
2575
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
2576
+ const hasRestoredCanvasSize =
2577
+ Number.isFinite(restoredCanvasWidth) &&
2578
+ restoredCanvasWidth > 0 &&
2579
+ Number.isFinite(restoredCanvasHeight) &&
2580
+ restoredCanvasHeight > 0;
2180
2581
  if (
2181
2582
  editorMetadata &&
2182
2583
  Object.prototype.hasOwnProperty.call(editorMetadata, 'version') &&
@@ -2185,7 +2586,7 @@ function ensureFabric() {
2185
2586
  this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
2186
2587
  }
2187
2588
 
2188
- this.canvas.loadFromJSON(state, async () => {
2589
+ const finishLoad = async () => {
2189
2590
  try {
2190
2591
  if (this._disposed || !this.canvas) {
2191
2592
  reject(new Error('Editor was disposed while loading state'));
@@ -2228,6 +2629,12 @@ function ensureFabric() {
2228
2629
  this.currentRotation = 0;
2229
2630
  }
2230
2631
 
2632
+ if (hasRestoredCanvasSize) {
2633
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2634
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2635
+ this._updateCanvasSizeToImageBounds();
2636
+ }
2637
+
2231
2638
  const masks = canvasObjects.filter(object => object.maskId);
2232
2639
  masks.forEach(mask => {
2233
2640
  this._restoreMaskControls(mask);
@@ -2259,7 +2666,9 @@ function ensureFabric() {
2259
2666
  this._reportError('loadFromState() failed', callbackError);
2260
2667
  reject(callbackError);
2261
2668
  }
2262
- });
2669
+ };
2670
+
2671
+ this.canvas.loadFromJSON(state, () => { void finishLoad(); });
2263
2672
 
2264
2673
  } catch (error) {
2265
2674
  this._reportError('loadFromState() failed', error);
@@ -2331,9 +2740,17 @@ function ensureFabric() {
2331
2740
  * @returns {void}
2332
2741
  * @public
2333
2742
  */
2334
- saveState() {
2743
+ saveState(options = {}) {
2335
2744
  if (!this.canvas) return;
2336
2745
 
2746
+ try {
2747
+ this._assertIdleForOperation('saveState', options);
2748
+ } catch (error) {
2749
+ this._reportError('saveState blocked', error);
2750
+ this._updateUI();
2751
+ return;
2752
+ }
2753
+
2337
2754
  try {
2338
2755
  const after = this._captureCanvasStateOrThrow('saveState');
2339
2756
  const before = this._lastSnapshot || after;
@@ -2341,14 +2758,14 @@ function ensureFabric() {
2341
2758
  let executedOnce = false;
2342
2759
 
2343
2760
  const command = new Command(
2344
- () => {
2761
+ (commandOptions = {}) => {
2345
2762
  if (executedOnce) {
2346
- return this.loadFromState(after);
2763
+ return this.loadFromState(after, commandOptions);
2347
2764
  }
2348
2765
  executedOnce = true;
2349
2766
  return undefined;
2350
2767
  },
2351
- () => this.loadFromState(before)
2768
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2352
2769
  );
2353
2770
 
2354
2771
  this.historyManager.execute(command);
@@ -2380,8 +2797,8 @@ function ensureFabric() {
2380
2797
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2381
2798
 
2382
2799
  const command = new Command(
2383
- () => this.loadFromState(after),
2384
- () => this.loadFromState(before)
2800
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2801
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2385
2802
  );
2386
2803
  this.historyManager.push(command);
2387
2804
  this._lastSnapshot = after;
@@ -2395,8 +2812,17 @@ function ensureFabric() {
2395
2812
  * @public
2396
2813
  */
2397
2814
  undo() {
2398
- return this.historyManager.undo()
2815
+ try {
2816
+ this._assertIdleForOperation('undo');
2817
+ } catch (error) {
2818
+ return Promise.reject(error);
2819
+ }
2820
+ const operationToken = this._beginBusyOperation('undo');
2821
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken))
2399
2822
  .then(() => { this._updateUI(); })
2823
+ .finally(() => {
2824
+ this._endBusyOperation(operationToken);
2825
+ })
2400
2826
  .catch(error => {
2401
2827
  this._reportError('undo failed', error);
2402
2828
  throw error;
@@ -2410,8 +2836,17 @@ function ensureFabric() {
2410
2836
  * @public
2411
2837
  */
2412
2838
  redo() {
2413
- return this.historyManager.redo()
2839
+ try {
2840
+ this._assertIdleForOperation('redo');
2841
+ } catch (error) {
2842
+ return Promise.reject(error);
2843
+ }
2844
+ const operationToken = this._beginBusyOperation('redo');
2845
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken))
2414
2846
  .then(() => { this._updateUI(); })
2847
+ .finally(() => {
2848
+ this._endBusyOperation(operationToken);
2849
+ })
2415
2850
  .catch(error => {
2416
2851
  this._reportError('redo failed', error);
2417
2852
  throw error;
@@ -2420,12 +2855,7 @@ function ensureFabric() {
2420
2855
 
2421
2856
  _rebindMaskEvents(mask) {
2422
2857
  if (!mask) return;
2423
- if (mask.__imageEditorMaskHandlers) {
2424
- try {
2425
- mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
2426
- mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
2427
- } catch (error) { void error; }
2428
- }
2858
+ this._cleanupMaskEvents(mask);
2429
2859
 
2430
2860
  const metadata = {};
2431
2861
  if (!Number.isFinite(Number(mask.originalAlpha))) {
@@ -2456,6 +2886,19 @@ function ensureFabric() {
2456
2886
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2457
2887
  }
2458
2888
 
2889
+ _cleanupMaskEvents(mask) {
2890
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2891
+ try {
2892
+ if (typeof mask.off === 'function') {
2893
+ mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
2894
+ mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
2895
+ }
2896
+ } catch (error) {
2897
+ this._reportWarning('Mask event cleanup failed', error);
2898
+ }
2899
+ try { delete mask.__imageEditorMaskHandlers; } catch (error) { this._reportWarning('Mask event metadata cleanup failed', error); }
2900
+ }
2901
+
2459
2902
  /**
2460
2903
  * Creates a mask and adds it to the canvas.
2461
2904
  *
@@ -2527,21 +2970,49 @@ function ensureFabric() {
2527
2970
  return value != null ? value : fallback;
2528
2971
  };
2529
2972
 
2530
- if (maskConfig.left === undefined && this._lastMask) {
2531
- const previousMask = this._lastMask;
2532
- if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
2533
- const previousBounds = typeof previousMask.getBoundingRect === 'function'
2534
- ? previousMask.getBoundingRect(true, true)
2535
- : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2536
- left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2537
- top = Math.round(previousBounds.top ?? firstOffset);
2538
- } else {
2539
- left = resolveValue(maskConfig.left, firstOffset, 'width');
2540
- top = resolveValue(maskConfig.top, firstOffset, 'height');
2973
+ const rejectInvalidMask = (message, error = null) => {
2974
+ this._reportWarning(`createMask: ${message}`, error);
2975
+ return null;
2976
+ };
2977
+
2978
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
2979
+ const resolvedValue = resolveValue(value, fallback, axis);
2980
+ const numericValue = Number(resolvedValue);
2981
+ if (!Number.isFinite(numericValue)) {
2982
+ throw new Error(`${fieldName} must be a finite number`);
2983
+ }
2984
+ if (constraints.positive && numericValue <= 0) {
2985
+ throw new Error(`${fieldName} must be greater than 0`);
2986
+ }
2987
+ if (constraints.nonNegative && numericValue < 0) {
2988
+ throw new Error(`${fieldName} must be 0 or greater`);
2989
+ }
2990
+ return numericValue;
2991
+ };
2992
+
2993
+ try {
2994
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, 'width', 'gap', { nonNegative: true });
2995
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, 'width', 'width', { positive: true });
2996
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, 'height', 'height', { positive: true });
2997
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, 'width', 'angle');
2998
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, 'width', 'alpha')));
2999
+
3000
+ if (maskConfig.left === undefined && this._lastMask) {
3001
+ const previousMask = this._lastMask;
3002
+ if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
3003
+ const previousBounds = typeof previousMask.getBoundingRect === 'function'
3004
+ ? previousMask.getBoundingRect(true, true)
3005
+ : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
3006
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
3007
+ top = Math.round(previousBounds.top ?? firstOffset);
3008
+ } else {
3009
+ left = resolveNumber(maskConfig.left, firstOffset, 'width', 'left');
3010
+ top = resolveNumber(maskConfig.top, firstOffset, 'height', 'top');
3011
+ }
3012
+ } catch (error) {
3013
+ return rejectInvalidMask('invalid numeric configuration', error);
2541
3014
  }
2542
3015
 
2543
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
2544
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
2545
3016
  maskConfig.left = left;
2546
3017
  maskConfig.top = top;
2547
3018
 
@@ -2551,9 +3022,14 @@ function ensureFabric() {
2551
3022
  } else {
2552
3023
  switch (shapeType) {
2553
3024
  case 'circle':
3025
+ try {
3026
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min', 'radius', { positive: true });
3027
+ } catch (error) {
3028
+ return rejectInvalidMask('invalid circle radius', error);
3029
+ }
2554
3030
  mask = new fabric.Circle({
2555
3031
  left, top,
2556
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
3032
+ radius: maskConfig.radius,
2557
3033
  fill: maskConfig.color,
2558
3034
  opacity: maskConfig.alpha,
2559
3035
  angle: maskConfig.angle,
@@ -2561,10 +3037,16 @@ function ensureFabric() {
2561
3037
  });
2562
3038
  break;
2563
3039
  case 'ellipse':
3040
+ try {
3041
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, 'width', 'rx', { positive: true });
3042
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, 'height', 'ry', { positive: true });
3043
+ } catch (error) {
3044
+ return rejectInvalidMask('invalid ellipse radius', error);
3045
+ }
2564
3046
  mask = new fabric.Ellipse({
2565
3047
  left, top,
2566
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
2567
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
3048
+ rx: maskConfig.rx,
3049
+ ry: maskConfig.ry,
2568
3050
  fill: maskConfig.color,
2569
3051
  opacity: maskConfig.alpha,
2570
3052
  angle: maskConfig.angle,
@@ -2573,11 +3055,20 @@ function ensureFabric() {
2573
3055
  break;
2574
3056
  case 'polygon': {
2575
3057
  let polygonPoints = maskConfig.points || [];
2576
- if (Array.isArray(polygonPoints) && polygonPoints.length) {
2577
- // Ensure numeric {x,y} objects for fabric.Polygon.
2578
- polygonPoints = polygonPoints.map(point => Array.isArray(point)
2579
- ? { x: Number(point[0]), y: Number(point[1]) }
2580
- : { x: Number(point.x), y: Number(point.y) });
3058
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
3059
+ return rejectInvalidMask('polygon masks require at least three points');
3060
+ }
3061
+ try {
3062
+ polygonPoints = polygonPoints.map(point => {
3063
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
3064
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
3065
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3066
+ throw new Error('polygon point coordinates must be finite numbers');
3067
+ }
3068
+ return { x, y };
3069
+ });
3070
+ } catch (error) {
3071
+ return rejectInvalidMask('invalid polygon points', error);
2581
3072
  }
2582
3073
  mask = new fabric.Polygon(polygonPoints, {
2583
3074
  left, top,
@@ -2590,10 +3081,16 @@ function ensureFabric() {
2590
3081
  }
2591
3082
  case 'rect':
2592
3083
  default:
3084
+ try {
3085
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, 'width', 'rx', { nonNegative: true });
3086
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, 'height', 'ry', { nonNegative: true });
3087
+ } catch (error) {
3088
+ return rejectInvalidMask('invalid rectangle corner radius', error);
3089
+ }
2593
3090
  mask = new fabric.Rect({
2594
3091
  left, top,
2595
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
2596
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
3092
+ width: maskConfig.width,
3093
+ height: maskConfig.height,
2597
3094
  fill: maskConfig.color,
2598
3095
  opacity: maskConfig.alpha,
2599
3096
  angle: maskConfig.angle,
@@ -2634,12 +3131,12 @@ function ensureFabric() {
2634
3131
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2635
3132
  });
2636
3133
  this._rebindMaskEvents(mask);
2637
- this._expandCanvasToFitObject(mask);
3134
+ this._expandCanvasToFitObjects([mask]);
2638
3135
 
2639
3136
  // Store placement values so the next mask can be positioned beside this one.
2640
3137
  this._lastMaskInitialLeft = left;
2641
3138
  this._lastMaskInitialTop = top;
2642
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
3139
+ this._lastMaskInitialWidth = maskConfig.width;
2643
3140
 
2644
3141
  const maskId = ++this.maskCounter;
2645
3142
  mask.set({
@@ -2686,6 +3183,7 @@ function ensureFabric() {
2686
3183
  this.canvas.discardActiveObject();
2687
3184
  selectedMasks.forEach(mask => {
2688
3185
  this._removeLabelForMask(mask);
3186
+ this._cleanupMaskEvents(mask);
2689
3187
  this.canvas.remove(mask);
2690
3188
  });
2691
3189
 
@@ -2712,7 +3210,10 @@ function ensureFabric() {
2712
3210
  const saveHistory = options.saveHistory !== false;
2713
3211
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2714
3212
  masks.forEach(mask => this._removeLabelForMask(mask));
2715
- masks.forEach(mask => this.canvas.remove(mask));
3213
+ masks.forEach(mask => {
3214
+ this._cleanupMaskEvents(mask);
3215
+ this.canvas.remove(mask);
3216
+ });
2716
3217
  this.canvas.discardActiveObject();
2717
3218
  this._lastMask = null;
2718
3219
  this._lastMaskInitialLeft = null;
@@ -2777,7 +3278,9 @@ function ensureFabric() {
2777
3278
  if (backup.visible !== undefined) backup.label.set({ visible: backup.visible });
2778
3279
  if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2779
3280
  this._syncMaskLabel(backup.mask);
2780
- } catch (error) { void error; }
3281
+ } catch (error) {
3282
+ this._reportWarning('restoreMaskLabelBackups: failed to restore mask label', error);
3283
+ }
2781
3284
  });
2782
3285
  }
2783
3286
 
@@ -2919,7 +3422,6 @@ function ensureFabric() {
2919
3422
  try {
2920
3423
  if (canvasObjectSet.has(label)) {
2921
3424
  this.canvas.remove(label);
2922
- canvasObjectSet.delete(label);
2923
3425
  }
2924
3426
  } catch (error) { void error; }
2925
3427
  });
@@ -3084,6 +3586,7 @@ function ensureFabric() {
3084
3586
  this._assertIdleForOperation('mergeMasks');
3085
3587
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3086
3588
  if (!masks.length) return;
3589
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
3087
3590
  const beforeJson = this._serializeCanvasState();
3088
3591
  const operationToken = this._beginBusyOperation('mergeMasks');
3089
3592
 
@@ -3104,12 +3607,13 @@ function ensureFabric() {
3104
3607
  preserveScroll: true,
3105
3608
  resetMaskCounter: false
3106
3609
  }));
3610
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
3107
3611
  const afterJson = this._serializeCanvasState();
3108
3612
  this._pushStateTransition(beforeJson, afterJson);
3109
3613
  } catch (error) {
3110
3614
  this._reportError('merge error', error);
3111
3615
  try {
3112
- await this.loadFromState(beforeJson);
3616
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
3113
3617
  } catch (restoreError) {
3114
3618
  this._reportError('mergeMasks rollback failed', restoreError);
3115
3619
  }
@@ -3171,13 +3675,19 @@ function ensureFabric() {
3171
3675
  */
3172
3676
  async exportImageBase64(options = {}) {
3173
3677
  if (!this.originalImage) throw new Error('No image loaded');
3678
+ options = options || {};
3174
3679
  this._assertIdleForOperation('exportImageBase64', options);
3680
+ const isNestedOperation = this._isOwnInternalOperation(options);
3681
+ const operationToken = isNestedOperation
3682
+ ? this._getInternalOperationToken(options)
3683
+ : this._beginBusyOperation('exportImageBase64');
3175
3684
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
3176
3685
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
3177
3686
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
3178
3687
  const format = this._normalizeImageFormat(options.fileType || options.format);
3179
3688
 
3180
- if (!exportImageArea) {
3689
+ try {
3690
+ if (!exportImageArea) {
3181
3691
  const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
3182
3692
  const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
3183
3693
  const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
@@ -3209,16 +3719,15 @@ function ensureFabric() {
3209
3719
  this._restoreActiveObjectBackup(activeObjectBackup);
3210
3720
  this.canvas.renderAll();
3211
3721
  }
3212
- }
3722
+ }
3213
3723
 
3214
- // Render masks as export shapes without mutating their editable styles.
3215
- const masks = this.canvas.getObjects().filter(object => object.maskId);
3216
- const maskStyleBackups = this._captureMaskExportBackups(masks);
3217
- const labelBackups = this._captureMaskLabelBackups(masks);
3218
- const activeObjectBackup = this._captureActiveObjectBackup();
3724
+ // Render masks as export shapes without mutating their editable styles.
3725
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
3726
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3727
+ const labelBackups = this._captureMaskLabelBackups(masks);
3728
+ const activeObjectBackup = this._captureActiveObjectBackup();
3219
3729
 
3220
- let finalBase64;
3221
- try {
3730
+ try {
3222
3731
  // Labels are UI overlays and should not be part of the flattened export.
3223
3732
  masks.forEach(mask => this._removeLabelForMask(mask));
3224
3733
  this.canvas.discardActiveObject();
@@ -3236,8 +3745,7 @@ function ensureFabric() {
3236
3745
  const imageBounds = this.originalImage.getBoundingRect(true, true);
3237
3746
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
3238
3747
 
3239
- // Crop precisely in offscreen canvas
3240
- finalBase64 = await this._exportCanvasRegionToDataURL({
3748
+ return await this._exportCanvasRegionToDataURL({
3241
3749
  ...exportRegion,
3242
3750
  multiplier,
3243
3751
  quality,
@@ -3250,8 +3758,9 @@ function ensureFabric() {
3250
3758
  this._restoreActiveObjectBackup(activeObjectBackup);
3251
3759
  this.canvas.renderAll();
3252
3760
  }
3253
-
3254
- return finalBase64;
3761
+ } finally {
3762
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3763
+ }
3255
3764
  }
3256
3765
 
3257
3766
  /**
@@ -3285,7 +3794,12 @@ function ensureFabric() {
3285
3794
  */
3286
3795
  async exportImageFile(options = {}) {
3287
3796
  if (!this.originalImage) throw new Error('No image loaded');
3288
- this._assertIdleForOperation('exportImageFile');
3797
+ options = options || {};
3798
+ this._assertIdleForOperation('exportImageFile', options);
3799
+ const isNestedOperation = this._isOwnInternalOperation(options);
3800
+ const operationToken = isNestedOperation
3801
+ ? this._getInternalOperationToken(options)
3802
+ : this._beginBusyOperation('exportImageFile');
3289
3803
  const {
3290
3804
  mergeMask = true,
3291
3805
  fileType = 'jpeg',
@@ -3297,52 +3811,56 @@ function ensureFabric() {
3297
3811
  const safeFileType = this._normalizeImageFormat(fileType);
3298
3812
  const normalizedQuality = this._normalizeQuality(quality);
3299
3813
 
3300
- // Generate the data URL in the requested export mode.
3301
- let imageBase64;
3302
- if (mergeMask) {
3303
- imageBase64 = await this.exportImageBase64({
3304
- exportImageArea: true,
3305
- multiplier,
3306
- quality: normalizedQuality,
3307
- fileType: safeFileType
3308
- });
3309
- } else {
3310
- imageBase64 = await this.exportImageBase64({
3311
- exportImageArea: false,
3312
- multiplier,
3313
- quality: normalizedQuality,
3314
- fileType: safeFileType
3315
- });
3316
- }
3814
+ try {
3815
+ // Generate the data URL in the requested export mode.
3816
+ let imageBase64;
3817
+ if (mergeMask) {
3818
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3819
+ exportImageArea: true,
3820
+ multiplier,
3821
+ quality: normalizedQuality,
3822
+ fileType: safeFileType
3823
+ }));
3824
+ } else {
3825
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3826
+ exportImageArea: false,
3827
+ multiplier,
3828
+ quality: normalizedQuality,
3829
+ fileType: safeFileType
3830
+ }));
3831
+ }
3317
3832
 
3318
- // Convert to the required image format
3319
- let imageDataUrl = imageBase64;
3320
- if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3321
- // Redraw the exported data URL when the browser returned a different image format.
3322
- imageDataUrl = await new Promise((resolve, reject) => {
3323
- const imageElement = new window.Image();
3324
- imageElement.crossOrigin = "Anonymous";
3325
- imageElement.onload = () => {
3326
- try {
3327
- const offscreenCanvas = document.createElement('canvas');
3328
- offscreenCanvas.width = imageElement.width;
3329
- offscreenCanvas.height = imageElement.height;
3330
- const context = offscreenCanvas.getContext('2d');
3331
- if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
3332
- context.drawImage(imageElement, 0, 0);
3333
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3334
- resolve(convertedDataUrl);
3335
- } catch (error) { reject(error); }
3336
- };
3337
- imageElement.onerror = reject;
3338
- imageElement.src = imageBase64;
3339
- });
3340
- }
3833
+ // Convert to the required image format
3834
+ let imageDataUrl = imageBase64;
3835
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3836
+ // Redraw the exported data URL when the browser returned a different image format.
3837
+ imageDataUrl = await new Promise((resolve, reject) => {
3838
+ const imageElement = new window.Image();
3839
+ imageElement.crossOrigin = "Anonymous";
3840
+ imageElement.onload = () => {
3841
+ try {
3842
+ const offscreenCanvas = document.createElement('canvas');
3843
+ offscreenCanvas.width = imageElement.width;
3844
+ offscreenCanvas.height = imageElement.height;
3845
+ const context = offscreenCanvas.getContext('2d');
3846
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
3847
+ context.drawImage(imageElement, 0, 0);
3848
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3849
+ resolve(convertedDataUrl);
3850
+ } catch (error) { reject(error); }
3851
+ };
3852
+ imageElement.onerror = reject;
3853
+ imageElement.src = imageBase64;
3854
+ });
3855
+ }
3341
3856
 
3342
- // Convert the final data URL to a File with the requested MIME type.
3343
- const bytes = this._decodeBase64Payload(imageDataUrl.split(',')[1]);
3344
- const mime = `image/${safeFileType}`;
3345
- return new File([bytes], fileName, { type: mime });
3857
+ // Convert the final data URL to a File with the requested MIME type.
3858
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3859
+ const mime = `image/${safeFileType}`;
3860
+ return new File([bytes], fileName, { type: mime });
3861
+ } finally {
3862
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3863
+ }
3346
3864
  }
3347
3865
 
3348
3866
  _clearMaskPlacementMemory() {
@@ -3352,7 +3870,7 @@ function ensureFabric() {
3352
3870
  this._lastMaskInitialWidth = null;
3353
3871
  }
3354
3872
 
3355
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
3873
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
3356
3874
  this._reportError(message, error);
3357
3875
 
3358
3876
  if (this._cropRect && this.canvas) this._removeCropRect();
@@ -3365,7 +3883,7 @@ function ensureFabric() {
3365
3883
 
3366
3884
  if (beforeJson) {
3367
3885
  try {
3368
- await this.loadFromState(beforeJson);
3886
+ await this.loadFromState(beforeJson, options);
3369
3887
  } catch (restoreError) {
3370
3888
  this._reportError('applyCrop: rollback failed', restoreError);
3371
3889
  }
@@ -3391,24 +3909,37 @@ function ensureFabric() {
3391
3909
  }
3392
3910
 
3393
3911
  _removeCropRect() {
3394
- if (!this._cropRect) return;
3395
- try {
3396
- if (this._cropHandlers && this._cropHandlers.length) {
3397
- this._cropHandlers.forEach(targetHandlers => {
3398
- targetHandlers.handlers.forEach(handlerRecord => {
3912
+ if (this._cropHandlers && this._cropHandlers.length) {
3913
+ this._cropHandlers.forEach(targetHandlers => {
3914
+ (targetHandlers.handlers || []).forEach(handlerRecord => {
3915
+ try {
3399
3916
  if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
3400
3917
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3401
3918
  }
3402
- });
3919
+ } catch (error) {
3920
+ this._reportWarning('Crop handler cleanup failed', error);
3921
+ }
3403
3922
  });
3404
- }
3405
- } catch (error) { void error; }
3923
+ });
3924
+ }
3406
3925
 
3407
- try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
3926
+ try { if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect); } catch (error) { void error; }
3408
3927
  this._cropRect = null;
3409
3928
  this._cropHandlers = [];
3410
3929
  }
3411
3930
 
3931
+ _getCropRectContentBounds(cropRect) {
3932
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
3933
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
3934
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
3935
+ return {
3936
+ left: Number(cropRect.left) || 0,
3937
+ top: Number(cropRect.top) || 0,
3938
+ width,
3939
+ height
3940
+ };
3941
+ }
3942
+
3412
3943
  /**
3413
3944
  * Enters crop mode by creating a resizable crop rectangle above the base image.
3414
3945
  *
@@ -3439,14 +3970,19 @@ function ensureFabric() {
3439
3970
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
3440
3971
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
3441
3972
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
3442
- const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
3443
- const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
3973
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
3974
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
3444
3975
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3445
3976
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3446
3977
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3447
3978
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3448
3979
  const width = minCropWidth;
3449
3980
  const height = minCropHeight;
3981
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
3982
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
3983
+ this._cropRotationWarningEmitted = true;
3984
+ this._reportWarning('crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported');
3985
+ }
3450
3986
 
3451
3987
  // Visual style for the temporary crop rectangle.
3452
3988
  const cropRect = new fabric.Rect({
@@ -3458,8 +3994,8 @@ function ensureFabric() {
3458
3994
  strokeWidth: 1,
3459
3995
  strokeUniform: true,
3460
3996
  selectable: true,
3461
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3462
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
3997
+ hasRotatingPoint: false,
3998
+ lockRotation: true,
3463
3999
  cornerSize: 8,
3464
4000
  objectCaching: false,
3465
4001
  originX: 'left',
@@ -3502,7 +4038,7 @@ function ensureFabric() {
3502
4038
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3503
4039
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3504
4040
  cropRect.setCoords();
3505
- const cropBounds = cropRect.getBoundingRect(true, true);
4041
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3506
4042
  const imageLeft = Number(imageBounds.left) || 0;
3507
4043
  const imageTop = Number(imageBounds.top) || 0;
3508
4044
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3581,10 +4117,13 @@ function ensureFabric() {
3581
4117
  async applyCrop() {
3582
4118
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3583
4119
  this._assertIdleForOperation('applyCrop');
4120
+ const operationToken = this._beginBusyOperation('applyCrop');
4121
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3584
4122
 
4123
+ try {
3585
4124
  // Fabric does not update control coordinates automatically after programmatic transforms.
3586
4125
  this._cropRect.setCoords();
3587
- const rectBounds = this._cropRect.getBoundingRect(true, true);
4126
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3588
4127
 
3589
4128
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3590
4129
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
@@ -3595,9 +4134,13 @@ function ensureFabric() {
3595
4134
  try {
3596
4135
  beforeJson = this._serializeCanvasState();
3597
4136
  } catch (error) {
3598
- this._reportWarning('applyCrop: could not serialize before state', error);
4137
+ this._reportError('applyCrop: failed to capture rollback state', error);
3599
4138
  beforeJson = null;
3600
4139
  }
4140
+ if (!beforeJson) {
4141
+ this.cancelCrop();
4142
+ return;
4143
+ }
3601
4144
 
3602
4145
  const preservedMasks = [];
3603
4146
 
@@ -3613,6 +4156,7 @@ function ensureFabric() {
3613
4156
  maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3614
4157
  maskBounds.top + maskBounds.height > cropRegion.sourceY;
3615
4158
  this._removeLabelForMask(mask);
4159
+ this._cleanupMaskEvents(mask);
3616
4160
  this.canvas.remove(mask);
3617
4161
  if (shouldPreserveMasks && intersectsCrop) {
3618
4162
  this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
@@ -3625,7 +4169,7 @@ function ensureFabric() {
3625
4169
  this.canvas.renderAll();
3626
4170
  }
3627
4171
  } catch (error) {
3628
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
4172
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error, internalOptions);
3629
4173
  return;
3630
4174
  }
3631
4175
 
@@ -3646,13 +4190,13 @@ function ensureFabric() {
3646
4190
  format: 'jpeg'
3647
4191
  });
3648
4192
  } catch (error) {
3649
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
4193
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error, internalOptions);
3650
4194
  return;
3651
4195
  }
3652
4196
 
3653
4197
  // Load the cropped image as the new base image.
3654
4198
  try {
3655
- await this.loadImage(croppedBase64, { resetMaskCounter: false });
4199
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3656
4200
  if (preservedMasks.length) {
3657
4201
  preservedMasks.forEach(mask => {
3658
4202
  this._rebindMaskEvents(mask);
@@ -3665,7 +4209,7 @@ function ensureFabric() {
3665
4209
  this.canvas.renderAll();
3666
4210
  }
3667
4211
  } catch (error) {
3668
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
4212
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error, internalOptions);
3669
4213
  return;
3670
4214
  }
3671
4215
 
@@ -3687,6 +4231,9 @@ function ensureFabric() {
3687
4231
  // Refresh UI state after crop completion.
3688
4232
  this._updateUI();
3689
4233
  this.canvas.renderAll();
4234
+ } finally {
4235
+ this._endBusyOperation(operationToken);
4236
+ }
3690
4237
  }
3691
4238
 
3692
4239
 
@@ -3698,7 +4245,7 @@ function ensureFabric() {
3698
4245
  * @private
3699
4246
  */
3700
4247
  _updateInputs() {
3701
- const scaleInputElement = this._getElement('scaleRate');
4248
+ const scaleInputElement = this._getElement('scalePercentageInput');
3702
4249
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3703
4250
  }
3704
4251
 
@@ -3725,7 +4272,7 @@ function ensureFabric() {
3725
4272
  for (const key of Object.keys(this.elements || {})) {
3726
4273
  const element = this._getElement(key);
3727
4274
  if (!element) continue;
3728
- if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
4275
+ if (key === 'applyCropButton' || key === 'cancelCropButton' || key === 'applyCropBtn' || key === 'cancelCropBtn') {
3729
4276
  this._setDisabled(key, false);
3730
4277
  } else {
3731
4278
  this._setDisabled(key, true);
@@ -3734,24 +4281,24 @@ function ensureFabric() {
3734
4281
  return;
3735
4282
  }
3736
4283
 
3737
- this._setDisabled('zoomInBtn', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3738
- this._setDisabled('zoomOutBtn', !hasImage || isBusy || this.currentScale <= this.options.minScale);
3739
- this._setDisabled('rotateLeftBtn', !hasImage || isBusy);
3740
- this._setDisabled('rotateRightBtn', !hasImage || isBusy);
3741
- this._setDisabled('addMaskBtn', !hasImage || isBusy);
3742
- this._setDisabled('removeMaskBtn', !hasSelectedMask || isBusy);
3743
- this._setDisabled('removeAllMasksBtn', !hasMasks || isBusy);
3744
- this._setDisabled('mergeBtn', !hasImage || !hasMasks || isBusy);
3745
- this._setDisabled('downloadBtn', !hasImage || isBusy);
3746
- this._setDisabled('resetBtn', !hasImage || isDefaultTransform || isBusy);
3747
- this._setDisabled('undoBtn', !hasImage || isBusy || !canUndo);
3748
- this._setDisabled('redoBtn', !hasImage || isBusy || !canRedo);
3749
- this._setDisabled('cropBtn', !hasImage || isBusy);
3750
- this._setDisabled('applyCropBtn', true);
3751
- this._setDisabled('cancelCropBtn', true);
3752
- this._setDisabled('scaleRate', !hasImage || isBusy);
3753
- this._setDisabled('rotationLeftInput', !hasImage || isBusy);
3754
- this._setDisabled('rotationRightInput', !hasImage || isBusy);
4284
+ this._setDisabled('zoomInButton', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
4285
+ this._setDisabled('zoomOutButton', !hasImage || isBusy || this.currentScale <= this.options.minScale);
4286
+ this._setDisabled('rotateLeftButton', !hasImage || isBusy);
4287
+ this._setDisabled('rotateRightButton', !hasImage || isBusy);
4288
+ this._setDisabled('createMaskButton', !hasImage || isBusy);
4289
+ this._setDisabled('removeSelectedMaskButton', !hasSelectedMask || isBusy);
4290
+ this._setDisabled('removeAllMasksButton', !hasMasks || isBusy);
4291
+ this._setDisabled('mergeMasksButton', !hasImage || !hasMasks || isBusy);
4292
+ this._setDisabled('downloadImageButton', !hasImage || isBusy);
4293
+ this._setDisabled('resetImageTransformButton', !hasImage || isDefaultTransform || isBusy);
4294
+ this._setDisabled('undoButton', !hasImage || isBusy || !canUndo);
4295
+ this._setDisabled('redoButton', !hasImage || isBusy || !canRedo);
4296
+ this._setDisabled('enterCropModeButton', !hasImage || isBusy);
4297
+ this._setDisabled('applyCropButton', true);
4298
+ this._setDisabled('cancelCropButton', true);
4299
+ this._setDisabled('scalePercentageInput', !hasImage || isBusy);
4300
+ this._setDisabled('rotateLeftDegreesInput', !hasImage || isBusy);
4301
+ this._setDisabled('rotateRightDegreesInput', !hasImage || isBusy);
3755
4302
  this._setDisabled('maskList', !hasImage || isBusy);
3756
4303
  this._setDisabled('imageInput', isBusy);
3757
4304
  this._setDisabled('uploadArea', isBusy);
@@ -3760,7 +4307,7 @@ function ensureFabric() {
3760
4307
  /**
3761
4308
  * Enables or disables a specific UI element (typically a button) by its key.
3762
4309
  *
3763
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
4310
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3764
4311
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3765
4312
  * @private
3766
4313
  */
@@ -3895,10 +4442,7 @@ function ensureFabric() {
3895
4442
  }
3896
4443
  } catch (error) { void error; }
3897
4444
 
3898
- if (this._cropRect) {
3899
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3900
- this._cropRect = null;
3901
- }
4445
+ if (this._cropRect) this._removeCropRect();
3902
4446
 
3903
4447
  if (this.containerElement && this._containerOriginalOverflow) {
3904
4448
  try { this._restoreContainerOverflowState(); } catch (error) { void error; }
@@ -3918,10 +4462,16 @@ function ensureFabric() {
3918
4462
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3919
4463
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3920
4464
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
4465
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3921
4466
  } catch (error) { void error; }
3922
4467
  }
3923
4468
 
3924
4469
  if (this.canvas) {
4470
+ try {
4471
+ this.canvas.getObjects().forEach(object => {
4472
+ if (object && object.maskId) this._cleanupMaskEvents(object);
4473
+ });
4474
+ } catch (error) { void error; }
3925
4475
  try { this.canvas.dispose(); } catch (error) { void error; }
3926
4476
  this.canvas = null;
3927
4477
  this.canvasElement = null;
@@ -3973,6 +4523,7 @@ function ensureFabric() {
3973
4523
 
3974
4524
  /**
3975
4525
  * @callback HistoryTaskCallback
4526
+ * @param {Object} [options] - Internal operation options passed by the editor.
3976
4527
  * @returns {void|Promise<void>} Result of a history operation.
3977
4528
  */
3978
4529
 
@@ -4063,7 +4614,7 @@ function ensureFabric() {
4063
4614
  task.reject(error);
4064
4615
  }
4065
4616
  } finally {
4066
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
4617
+ if (this.currentTask === task) this.currentTask = null;
4067
4618
  }
4068
4619
  }
4069
4620
  } finally {
@@ -4194,11 +4745,11 @@ function ensureFabric() {
4194
4745
  *
4195
4746
  * @returns {Promise<void>} Resolves after the undo task completes.
4196
4747
  */
4197
- undo() {
4748
+ undo(options = {}) {
4198
4749
  return this.enqueue(async () => {
4199
4750
  if (this.currentIndex >= 0) {
4200
4751
  const index = this.currentIndex;
4201
- await this.history[index].undo();
4752
+ await this.history[index].undo(options);
4202
4753
  this.currentIndex = index - 1;
4203
4754
  }
4204
4755
  });
@@ -4209,11 +4760,11 @@ function ensureFabric() {
4209
4760
  *
4210
4761
  * @returns {Promise<void>} Resolves after the redo task completes.
4211
4762
  */
4212
- redo() {
4763
+ redo(options = {}) {
4213
4764
  return this.enqueue(async () => {
4214
4765
  if (this.currentIndex < this.history.length - 1) {
4215
4766
  const index = this.currentIndex + 1;
4216
- await this.history[index].execute();
4767
+ await this.history[index].execute(options);
4217
4768
  this.currentIndex = index;
4218
4769
  }
4219
4770
  });