@bensitu/image-editor 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.4.1
4
+ * @version 1.5.0
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
8
8
  */
9
9
 
10
10
  let fabric = null;
11
- const INTERNAL_OPERATION_TOKEN = Symbol('ImageEditorInternalOperation');
11
+ const INTERNAL_OPERATION_TOKEN = Symbol.for('ImageEditorInternalOperation');
12
12
 
13
13
  /**
14
14
  * Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
@@ -284,7 +284,7 @@ function ensureFabric() {
284
284
  this._disposed = false;
285
285
  this._initialized = false;
286
286
 
287
- this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
287
+ this.onImageLoaded = typeof this.options.onImageLoaded === 'function' ? this.options.onImageLoaded : null;
288
288
 
289
289
  this.animationQueue = new AnimationQueue();
290
290
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -338,10 +338,12 @@ function ensureFabric() {
338
338
  * Use this method to set up the editor UI before interacting with it.
339
339
  *
340
340
  * @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.
341
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
342
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
343
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
344
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
345
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
346
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
345
347
  *
346
348
  * @returns {void}
347
349
  *
@@ -350,7 +352,7 @@ function ensureFabric() {
350
352
  * @example
351
353
  * editor.init({
352
354
  * canvas: 'myFabricCanvasId',
353
- * downloadBtn: 'myDownloadButtonId'
355
+ * downloadImageButton: 'myDownloadButtonId'
354
356
  * });
355
357
  */
356
358
  init(idMap = {}) {
@@ -369,34 +371,54 @@ function ensureFabric() {
369
371
  this._containerOriginalOverflow = null;
370
372
  this._lastContainerViewportSize = null;
371
373
  this._canvasElementOriginalStyle = null;
374
+ this._deprecatedElementKeyWarnings = new Set();
372
375
 
373
376
  const defaults = {
374
377
  canvas: 'fabricCanvas',
375
378
  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',
379
+ imagePlaceholder: 'imagePlaceholder',
380
+ imgPlaceholder: null,
381
+ scalePercentageInput: 'scalePercentageInput',
382
+ scaleRate: null,
383
+ rotateLeftDegreesInput: 'rotateLeftDegreesInput',
384
+ rotationLeftInput: null,
385
+ rotateRightDegreesInput: 'rotateRightDegreesInput',
386
+ rotationRightInput: null,
387
+ rotateLeftButton: 'rotateLeftButton',
388
+ rotateLeftBtn: null,
389
+ rotateRightButton: 'rotateRightButton',
390
+ rotateRightBtn: null,
391
+ createMaskButton: 'createMaskButton',
392
+ addMaskBtn: null,
393
+ removeSelectedMaskButton: 'removeSelectedMaskButton',
394
+ removeMaskBtn: null,
395
+ removeAllMasksButton: 'removeAllMasksButton',
396
+ removeAllMasksBtn: null,
397
+ mergeMasksButton: 'mergeMasksButton',
398
+ mergeBtn: null,
399
+ downloadImageButton: 'downloadImageButton',
400
+ downloadBtn: null,
387
401
  maskList: 'maskList',
388
- zoomInBtn: 'zoomInBtn',
389
- zoomOutBtn: 'zoomOutBtn',
390
- resetBtn: 'resetBtn',
391
- undoBtn: 'undoBtn',
392
- redoBtn: 'redoBtn',
402
+ zoomInButton: 'zoomInButton',
403
+ zoomInBtn: null,
404
+ zoomOutButton: 'zoomOutButton',
405
+ zoomOutBtn: null,
406
+ resetImageTransformButton: 'resetImageTransformButton',
407
+ resetBtn: null,
408
+ undoButton: 'undoButton',
409
+ undoBtn: null,
410
+ redoButton: 'redoButton',
411
+ redoBtn: null,
393
412
  imageInput: 'imageInput',
394
- cropBtn: 'cropBtn',
395
- applyCropBtn: 'applyCropBtn',
396
- cancelCropBtn: 'cancelCropBtn'
413
+ enterCropModeButton: 'enterCropModeButton',
414
+ cropBtn: null,
415
+ applyCropButton: 'applyCropButton',
416
+ applyCropBtn: null,
417
+ cancelCropButton: 'cancelCropButton',
418
+ cancelCropBtn: null
397
419
  };
398
420
 
399
- this.elements = { ...defaults, ...idMap };
421
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
400
422
  this._elementCache = {};
401
423
 
402
424
  this._initCanvas();
@@ -413,6 +435,73 @@ function ensureFabric() {
413
435
  }
414
436
  }
415
437
 
438
+ _resolveElementIdMap(idMap, defaults) {
439
+ const resolved = { ...defaults, ...idMap };
440
+
441
+ this._resolveElementAliases(resolved, idMap, defaults, 'imagePlaceholder', ['imgPlaceholder']);
442
+ this._resolveElementAliases(resolved, idMap, defaults, 'scalePercentageInput', ['scaleRate']);
443
+ this._resolveElementAliases(resolved, idMap, defaults, 'rotateLeftDegreesInput', ['rotationLeftInput']);
444
+ this._resolveElementAliases(resolved, idMap, defaults, 'rotateRightDegreesInput', ['rotationRightInput']);
445
+ this._resolveElementAlias(resolved, idMap, defaults, 'rotateLeftButton', 'rotateLeftBtn');
446
+ this._resolveElementAlias(resolved, idMap, defaults, 'rotateRightButton', 'rotateRightBtn');
447
+ this._resolveElementAlias(resolved, idMap, defaults, 'createMaskButton', 'addMaskBtn');
448
+ this._resolveElementAliases(resolved, idMap, defaults, 'removeSelectedMaskButton', ['removeMaskBtn']);
449
+ this._resolveElementAlias(resolved, idMap, defaults, 'removeAllMasksButton', 'removeAllMasksBtn');
450
+ this._resolveElementAlias(resolved, idMap, defaults, 'mergeMasksButton', 'mergeBtn');
451
+ this._resolveElementAliases(resolved, idMap, defaults, 'downloadImageButton', ['downloadBtn']);
452
+ this._resolveElementAlias(resolved, idMap, defaults, 'zoomInButton', 'zoomInBtn');
453
+ this._resolveElementAlias(resolved, idMap, defaults, 'zoomOutButton', 'zoomOutBtn');
454
+ this._resolveElementAlias(resolved, idMap, defaults, 'resetImageTransformButton', 'resetBtn');
455
+ this._resolveElementAlias(resolved, idMap, defaults, 'undoButton', 'undoBtn');
456
+ this._resolveElementAlias(resolved, idMap, defaults, 'redoButton', 'redoBtn');
457
+ this._resolveElementAliases(resolved, idMap, defaults, 'enterCropModeButton', ['cropBtn']);
458
+ this._resolveElementAlias(resolved, idMap, defaults, 'applyCropButton', 'applyCropBtn');
459
+ this._resolveElementAlias(resolved, idMap, defaults, 'cancelCropButton', 'cancelCropBtn');
460
+
461
+ return resolved;
462
+ }
463
+
464
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
465
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
466
+ }
467
+
468
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
469
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
470
+
471
+ if (hasCanonicalKey) {
472
+ resolved[canonicalKey] = idMap[canonicalKey];
473
+ return;
474
+ }
475
+
476
+ let deprecatedValue;
477
+ let hasDeprecatedValue = false;
478
+ for (const deprecatedKey of deprecatedKeys) {
479
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
480
+ if (!hasDeprecatedValue) {
481
+ deprecatedValue = idMap[deprecatedKey];
482
+ hasDeprecatedValue = true;
483
+ }
484
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
485
+ }
486
+ }
487
+
488
+ if (hasDeprecatedValue) {
489
+ resolved[canonicalKey] = deprecatedValue;
490
+ return;
491
+ }
492
+
493
+ resolved[canonicalKey] = defaults[canonicalKey];
494
+ }
495
+
496
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
497
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = new Set();
498
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
499
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
500
+ this._reportWarning(
501
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
502
+ );
503
+ }
504
+
416
505
  _reportError(message, error = null) {
417
506
  const handler = this.options && this.options.onError;
418
507
  if (typeof handler !== 'function') return;
@@ -435,6 +524,12 @@ function ensureFabric() {
435
524
  }
436
525
  }
437
526
 
527
+ _notifyImageLoaded() {
528
+ const optionsCallback = this.options && this.options.onImageLoaded;
529
+ const callback = typeof optionsCallback === 'function' ? optionsCallback : this.onImageLoaded;
530
+ if (typeof callback === 'function') callback();
531
+ }
532
+
438
533
  /**
439
534
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
440
535
  *
@@ -460,7 +555,7 @@ function ensureFabric() {
460
555
  this.containerElement = canvasElement.parentElement;
461
556
  }
462
557
 
463
- this.placeholderElement = this._getElement('imgPlaceholder') || null;
558
+ this.placeholderElement = this._getElement('imagePlaceholder') || null;
464
559
 
465
560
  // Prefer a measured container size when it is available.
466
561
  let initialWidth = this.options.canvasWidth;
@@ -628,23 +723,23 @@ function ensureFabric() {
628
723
  }
629
724
  });
630
725
  // 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)); });
726
+ this._bindIfExists('zoomInButton', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
727
+ this._bindIfExists('zoomOutButton', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
728
+ this._bindIfExists('resetImageTransformButton', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
634
729
  // Mask management
635
- this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
636
- this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
637
- this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
730
+ this._bindIfExists('createMaskButton', 'click', () => this.createMask());
731
+ this._bindIfExists('removeSelectedMaskButton', 'click', () => this.removeSelectedMask());
732
+ this._bindIfExists('removeAllMasksButton', 'click', () => this.removeAllMasks());
638
733
  // Merge + download
639
- this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
640
- this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
734
+ this._bindIfExists('mergeMasksButton', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
735
+ this._bindIfExists('downloadImageButton', 'click', () => this.downloadImage());
641
736
  // 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)));
737
+ this._bindIfExists('undoButton', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
738
+ this._bindIfExists('redoButton', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
644
739
 
645
740
  // Rotation buttons (step can be overridden by two input fields)
646
- this._bindIfExists('rotateLeftBtn', 'click', () => {
647
- const rotationInputElement = this._getElement('rotationLeftInput');
741
+ this._bindIfExists('rotateLeftButton', 'click', () => {
742
+ const rotationInputElement = this._getElement('rotateLeftDegreesInput');
648
743
  let step = this.options.rotationStep;
649
744
  if (rotationInputElement) {
650
745
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -652,8 +747,8 @@ function ensureFabric() {
652
747
  }
653
748
  this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
654
749
  });
655
- this._bindIfExists('rotateRightBtn', 'click', () => {
656
- const rotationInputElement = this._getElement('rotationRightInput');
750
+ this._bindIfExists('rotateRightButton', 'click', () => {
751
+ const rotationInputElement = this._getElement('rotateRightDegreesInput');
657
752
  let step = this.options.rotationStep;
658
753
  if (rotationInputElement) {
659
754
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -663,9 +758,9 @@ function ensureFabric() {
663
758
  });
664
759
 
665
760
  // 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());
761
+ this._bindIfExists('enterCropModeButton', 'click', () => this.enterCropMode());
762
+ this._bindIfExists('applyCropButton', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
763
+ this._bindIfExists('cancelCropButton', 'click', () => this.cancelCrop());
669
764
  this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
670
765
  }
671
766
 
@@ -768,14 +863,16 @@ function ensureFabric() {
768
863
  if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
769
864
 
770
865
  let loadSource = imageBase64;
771
- if (this.options.downsampleOnLoad) {
866
+ const downsampleMaxWidth = Number(this.options.downsampleMaxWidth);
867
+ const downsampleMaxHeight = Number(this.options.downsampleMaxHeight);
868
+ if (this.options.downsampleOnLoad && downsampleMaxWidth > 0 && downsampleMaxHeight > 0) {
772
869
  const shouldResize =
773
- imageElement.naturalWidth > this.options.downsampleMaxWidth ||
774
- imageElement.naturalHeight > this.options.downsampleMaxHeight;
870
+ imageElement.naturalWidth > downsampleMaxWidth ||
871
+ imageElement.naturalHeight > downsampleMaxHeight;
775
872
  if (shouldResize) {
776
873
  const ratio = Math.min(
777
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
778
- this.options.downsampleMaxHeight / imageElement.naturalHeight
874
+ downsampleMaxWidth / imageElement.naturalWidth,
875
+ downsampleMaxHeight / imageElement.naturalHeight
779
876
  );
780
877
  const targetWidth = Math.round(imageElement.naturalWidth * ratio);
781
878
  const targetHeight = Math.round(imageElement.naturalHeight * ratio);
@@ -787,6 +884,8 @@ function ensureFabric() {
787
884
  imageBase64
788
885
  );
789
886
  }
887
+ } else if (this.options.downsampleOnLoad) {
888
+ this._reportWarning('loadImage: downsample limits must be positive numbers; using the original image');
790
889
  }
791
890
 
792
891
  const fabricImage = await this._createFabricImageFromURL(loadSource);
@@ -856,9 +955,7 @@ function ensureFabric() {
856
955
  this.canvas.renderAll();
857
956
  this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
858
957
 
859
- if (typeof this.onImageLoaded === 'function') {
860
- this.onImageLoaded();
861
- }
958
+ this._notifyImageLoaded();
862
959
  } catch (error) {
863
960
  await this._rollbackLoadImageTransaction(transaction);
864
961
  throw error;
@@ -883,6 +980,22 @@ function ensureFabric() {
883
980
  );
884
981
  }
885
982
 
983
+ /**
984
+ * Checks whether the editor is in a temporary non-mutating state.
985
+ *
986
+ * @returns {boolean} True while loading, animating, cropping, or running a compound operation.
987
+ * @public
988
+ */
989
+ isBusy() {
990
+ return !!(
991
+ this.isAnimating ||
992
+ this._cropMode ||
993
+ this._isLoading ||
994
+ this._activeOperationToken ||
995
+ (this.animationQueue && this.animationQueue.isBusy())
996
+ );
997
+ }
998
+
886
999
  /**
887
1000
  * Creates an HTMLImageElement from a given data URL.
888
1001
  *
@@ -909,7 +1022,7 @@ function ensureFabric() {
909
1022
  };
910
1023
  timerId = setTimeout(() => {
911
1024
  settle(() => reject(new Error('Image load timed out')));
912
- try { imageElement.src = ''; } catch (error) { void error; }
1025
+ try { imageElement.src = ''; } catch (error) { this._reportWarning('Image timeout cleanup failed', error); }
913
1026
  }, safeTimeoutMs);
914
1027
  imageElement.onload = () => settle(() => resolve(imageElement));
915
1028
  imageElement.onerror = (error) => settle(() => reject(error));
@@ -957,7 +1070,6 @@ function ensureFabric() {
957
1070
  _captureLoadImageTransaction() {
958
1071
  return {
959
1072
  canvasState: this._serializeCanvasState(),
960
- originalImage: this.originalImage,
961
1073
  baseImageScale: this.baseImageScale,
962
1074
  currentScale: this.currentScale,
963
1075
  currentRotation: this.currentRotation,
@@ -983,6 +1095,7 @@ function ensureFabric() {
983
1095
  async _rollbackLoadImageTransaction(transaction) {
984
1096
  if (!transaction || !this.canvas || this._disposed) return;
985
1097
  let didRestoreCanvasState = false;
1098
+ let didFailCanvasRestore = false;
986
1099
  try {
987
1100
  if (transaction.canvasState) {
988
1101
  await this.loadFromState(transaction.canvasState);
@@ -990,23 +1103,28 @@ function ensureFabric() {
990
1103
  }
991
1104
  } catch (error) {
992
1105
  this._lastMask = null;
1106
+ didFailCanvasRestore = true;
993
1107
  this._reportError('loadImage rollback failed', error);
994
1108
  }
995
1109
 
996
- this.baseImageScale = transaction.baseImageScale;
997
- this.currentScale = transaction.currentScale;
998
- this.currentRotation = transaction.currentRotation;
999
- this.maskCounter = transaction.maskCounter;
1000
- this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
1001
- this._lastSnapshot = transaction.lastSnapshot;
1002
- if (didRestoreCanvasState) {
1003
- this._restoreLastMaskReference(transaction.lastMask);
1110
+ if (didFailCanvasRestore) {
1111
+ this._reconcileEditorStateFromCanvas();
1004
1112
  } else {
1005
- this._lastMask = null;
1113
+ this.baseImageScale = transaction.baseImageScale;
1114
+ this.currentScale = transaction.currentScale;
1115
+ this.currentRotation = transaction.currentRotation;
1116
+ this.maskCounter = transaction.maskCounter;
1117
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
1118
+ this._lastSnapshot = transaction.lastSnapshot;
1119
+ if (didRestoreCanvasState) {
1120
+ this._restoreLastMaskReference(transaction.lastMask);
1121
+ } else {
1122
+ this._lastMask = null;
1123
+ }
1124
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1125
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1126
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1006
1127
  }
1007
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1008
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1009
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1010
1128
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
1011
1129
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
1012
1130
  if (this.containerElement) {
@@ -1020,6 +1138,49 @@ function ensureFabric() {
1020
1138
  if (this.canvas) this.canvas.renderAll();
1021
1139
  }
1022
1140
 
1141
+ _reconcileEditorStateFromCanvas() {
1142
+ if (!this.canvas) {
1143
+ this.originalImage = null;
1144
+ this.baseImageScale = 1;
1145
+ this.currentScale = 1;
1146
+ this.currentRotation = 0;
1147
+ this.maskCounter = 0;
1148
+ this.isImageLoadedToCanvas = false;
1149
+ this._lastSnapshot = null;
1150
+ this._clearMaskPlacementMemory();
1151
+ return;
1152
+ }
1153
+
1154
+ const canvasObjects = this.canvas.getObjects();
1155
+ this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
1156
+ if (this.originalImage) {
1157
+ const imageScale = Number(this.originalImage.scaleX) || 1;
1158
+ this.baseImageScale = imageScale;
1159
+ this.currentScale = 1;
1160
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1161
+ } else {
1162
+ this.baseImageScale = 1;
1163
+ this.currentScale = 1;
1164
+ this.currentRotation = 0;
1165
+ }
1166
+
1167
+ const masks = canvasObjects.filter(object => object.maskId);
1168
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
1169
+ this._lastMask = masks[masks.length - 1] || null;
1170
+ if (!this._lastMask) {
1171
+ this._lastMaskInitialLeft = null;
1172
+ this._lastMaskInitialTop = null;
1173
+ this._lastMaskInitialWidth = null;
1174
+ }
1175
+ this.isImageLoadedToCanvas = !!this.originalImage;
1176
+ try {
1177
+ this._lastSnapshot = this._serializeCanvasState();
1178
+ } catch (error) {
1179
+ this._lastSnapshot = null;
1180
+ this._reportWarning('loadImage rollback: failed to reconcile canvas snapshot', error);
1181
+ }
1182
+ }
1183
+
1023
1184
  _restoreLastMaskReference(previousLastMask) {
1024
1185
  if (!this.canvas) {
1025
1186
  this._lastMask = null;
@@ -1048,12 +1209,20 @@ function ensureFabric() {
1048
1209
  * @private
1049
1210
  */
1050
1211
  _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
1212
+ const sourceWidth = Math.max(1, Number(imageElement && (imageElement.naturalWidth || imageElement.width)) || 0);
1213
+ const sourceHeight = Math.max(1, Number(imageElement && (imageElement.naturalHeight || imageElement.height)) || 0);
1214
+ const safeTargetWidth = Math.round(Number(targetWidth));
1215
+ const safeTargetHeight = Math.round(Number(targetHeight));
1216
+ if (!Number.isFinite(safeTargetWidth) || !Number.isFinite(safeTargetHeight) || safeTargetWidth <= 0 || safeTargetHeight <= 0) {
1217
+ throw new Error('Invalid image resample target dimensions');
1218
+ }
1219
+
1051
1220
  const offscreenCanvas = document.createElement('canvas');
1052
- offscreenCanvas.width = targetWidth;
1053
- offscreenCanvas.height = targetHeight;
1221
+ offscreenCanvas.width = safeTargetWidth;
1222
+ offscreenCanvas.height = safeTargetHeight;
1054
1223
  const context = offscreenCanvas.getContext('2d');
1055
1224
  if (!context) throw new Error('2D canvas context is unavailable');
1056
- context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
1225
+ context.drawImage(imageElement, 0, 0, sourceWidth, sourceHeight, 0, 0, safeTargetWidth, safeTargetHeight);
1057
1226
  return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
1058
1227
  }
1059
1228
 
@@ -1089,6 +1258,7 @@ function ensureFabric() {
1089
1258
  * @private
1090
1259
  */
1091
1260
  _setCanvasSizeInt(width, height) {
1261
+ if (!this.canvas) return;
1092
1262
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
1093
1263
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
1094
1264
  // Set fabric internal and also style attributes to keep DOM consistent
@@ -1400,7 +1570,7 @@ function ensureFabric() {
1400
1570
  /**
1401
1571
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1402
1572
  *
1403
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1573
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1404
1574
  * @private
1405
1575
  */
1406
1576
  _serializeEditorMetadata() {
@@ -1408,13 +1578,17 @@ function ensureFabric() {
1408
1578
  const currentScale = Number(this.currentScale);
1409
1579
  const currentRotation = Number(this.currentRotation);
1410
1580
  const maskCounter = Number(this.maskCounter);
1581
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1582
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1411
1583
 
1412
1584
  return {
1413
1585
  version: 1,
1414
1586
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1415
1587
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1416
1588
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1417
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1589
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1590
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1591
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1418
1592
  };
1419
1593
  }
1420
1594
 
@@ -1617,11 +1791,50 @@ function ensureFabric() {
1617
1791
 
1618
1792
  _getJpegBackgroundColor() {
1619
1793
  const backgroundColor = String(this.options.backgroundColor || '').trim();
1620
- if (!backgroundColor || backgroundColor === 'transparent') return '#ffffff';
1621
- if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return '#ffffff';
1794
+ if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return '#ffffff';
1622
1795
  return backgroundColor;
1623
1796
  }
1624
1797
 
1798
+ _isTransparentCssColor(color) {
1799
+ const normalizedColor = String(color || '').trim().toLowerCase();
1800
+ if (!normalizedColor || normalizedColor === 'transparent') return true;
1801
+
1802
+ const hexAlphaMatch = normalizedColor.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/i);
1803
+ if (hexAlphaMatch) {
1804
+ const alpha = hexAlphaMatch[1] || hexAlphaMatch[2];
1805
+ return alpha === '0' || alpha === '00';
1806
+ }
1807
+
1808
+ const slashAlphaMatch = normalizedColor.match(/^(?:rgba?|hsla?)\([^)]*\/\s*([^)]+)\)$/i);
1809
+ if (slashAlphaMatch) return this._isZeroCssAlpha(slashAlphaMatch[1]);
1810
+
1811
+ const commaAlphaMatch = normalizedColor.match(/^(?:rgba|hsla)\((.*)\)$/i);
1812
+ if (commaAlphaMatch) {
1813
+ const parts = commaAlphaMatch[1].split(',');
1814
+ if (parts.length >= 4) return this._isZeroCssAlpha(parts[parts.length - 1]);
1815
+ }
1816
+
1817
+ return false;
1818
+ }
1819
+
1820
+ _isZeroCssAlpha(alphaValue) {
1821
+ const normalizedAlpha = String(alphaValue || '').trim();
1822
+ if (!normalizedAlpha) return false;
1823
+ if (normalizedAlpha.endsWith('%')) return Number.parseFloat(normalizedAlpha) === 0;
1824
+ return Number(normalizedAlpha) === 0;
1825
+ }
1826
+
1827
+ _decodeBase64Payload(base64Payload) {
1828
+ const payload = String(base64Payload || '');
1829
+ if (typeof atob === 'function') {
1830
+ return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
1831
+ }
1832
+ if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
1833
+ return new Uint8Array(Buffer.from(payload, 'base64'));
1834
+ }
1835
+ throw new Error('Base64 decoding is unavailable');
1836
+ }
1837
+
1625
1838
  /**
1626
1839
  * Gets the top-left corner coordinates of the given object.
1627
1840
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1747,19 +1960,14 @@ function ensureFabric() {
1747
1960
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1748
1961
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1749
1962
  });
1750
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1751
-
1752
1963
  let minWidth = 0;
1753
1964
  let minHeight = 0;
1754
- if (shouldUseScrollSafeViewport) {
1965
+ if (this.containerElement) {
1755
1966
  const viewport = this._getContainerViewportSize();
1756
1967
  const safetyMargin = this._getScrollSafetyMargin();
1757
1968
 
1758
1969
  minWidth = Math.max(1, viewport.width - safetyMargin);
1759
1970
  minHeight = Math.max(1, viewport.height - safetyMargin);
1760
- } else if (this.containerElement) {
1761
- minWidth = Math.floor(this.containerElement.clientWidth || 0);
1762
- minHeight = Math.floor(this.containerElement.clientHeight || 0);
1763
1971
  }
1764
1972
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1765
1973
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1838,9 +2046,16 @@ function ensureFabric() {
1838
2046
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1839
2047
  }
1840
2048
 
2049
+ _isCropModeAllowedOperation(operationName) {
2050
+ return operationName === 'applyCrop' || operationName === 'cancelCrop';
2051
+ }
2052
+
1841
2053
  _assertIdleForOperation(operationName, options = {}) {
1842
2054
  this._assertEditorAvailable(operationName);
1843
2055
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
2056
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2057
+ throw new Error(`${operationName} cannot run while crop mode is active`);
2058
+ }
1844
2059
  if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1845
2060
  throw new Error(`${operationName} cannot run while an animation is running`);
1846
2061
  }
@@ -1854,10 +2069,14 @@ function ensureFabric() {
1854
2069
 
1855
2070
  _assertCanQueueAnimation(operationName, options = {}) {
1856
2071
  this._assertEditorAvailable(operationName);
1857
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
2072
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
2073
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2074
+ throw new Error(`${operationName} cannot run while crop mode is active`);
2075
+ }
2076
+ if (this._isLoading && !isOwnInternalOperation) {
1858
2077
  throw new Error(`${operationName} cannot run while an image is loading`);
1859
2078
  }
1860
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
2079
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1861
2080
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1862
2081
  }
1863
2082
  }
@@ -2064,10 +2283,19 @@ function ensureFabric() {
2064
2283
 
2065
2284
  return this.animationQueue.add(async () => {
2066
2285
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
2067
- await this._scaleImageImpl(1, { saveHistory: false });
2068
- await this._rotateImageImpl(0, { saveHistory: false });
2069
- const after = this._captureCanvasStateOrThrow('resetImageTransform');
2070
- this._pushStateTransition(before, after);
2286
+ try {
2287
+ await this._scaleImageImpl(1, { saveHistory: false });
2288
+ await this._rotateImageImpl(0, { saveHistory: false });
2289
+ const after = this._captureCanvasStateOrThrow('resetImageTransform');
2290
+ this._pushStateTransition(before, after);
2291
+ } catch (error) {
2292
+ try {
2293
+ await this.loadFromState(before);
2294
+ } catch (restoreError) {
2295
+ this._reportError('resetImageTransform rollback failed', restoreError);
2296
+ }
2297
+ throw error;
2298
+ }
2071
2299
  }).finally(() => {
2072
2300
  if (!this._disposed && this.canvas) this._updateUI();
2073
2301
  }).catch(error => {
@@ -2111,8 +2339,22 @@ function ensureFabric() {
2111
2339
  ? JSON.parse(serializedState)
2112
2340
  : serializedState;
2113
2341
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
2342
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
2343
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
2344
+ const hasRestoredCanvasSize =
2345
+ Number.isFinite(restoredCanvasWidth) &&
2346
+ restoredCanvasWidth > 0 &&
2347
+ Number.isFinite(restoredCanvasHeight) &&
2348
+ restoredCanvasHeight > 0;
2349
+ if (
2350
+ editorMetadata &&
2351
+ Object.prototype.hasOwnProperty.call(editorMetadata, 'version') &&
2352
+ Number(editorMetadata.version) !== 1
2353
+ ) {
2354
+ this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
2355
+ }
2114
2356
 
2115
- this.canvas.loadFromJSON(state, async () => {
2357
+ const finishLoad = async () => {
2116
2358
  try {
2117
2359
  if (this._disposed || !this.canvas) {
2118
2360
  reject(new Error('Editor was disposed while loading state'));
@@ -2155,6 +2397,12 @@ function ensureFabric() {
2155
2397
  this.currentRotation = 0;
2156
2398
  }
2157
2399
 
2400
+ if (hasRestoredCanvasSize) {
2401
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2402
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2403
+ this._updateCanvasSizeToImageBounds();
2404
+ }
2405
+
2158
2406
  const masks = canvasObjects.filter(object => object.maskId);
2159
2407
  masks.forEach(mask => {
2160
2408
  this._restoreMaskControls(mask);
@@ -2186,7 +2434,9 @@ function ensureFabric() {
2186
2434
  this._reportError('loadFromState() failed', callbackError);
2187
2435
  reject(callbackError);
2188
2436
  }
2189
- });
2437
+ };
2438
+
2439
+ this.canvas.loadFromJSON(state, () => { void finishLoad(); });
2190
2440
 
2191
2441
  } catch (error) {
2192
2442
  this._reportError('loadFromState() failed', error);
@@ -2204,12 +2454,13 @@ function ensureFabric() {
2204
2454
 
2205
2455
  _waitForImageElementReady(imageElement) {
2206
2456
  if (!imageElement) return Promise.resolve();
2207
- if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
2457
+ const hasLoadedDimensions = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) &&
2458
+ (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2459
+ if (hasLoadedDimensions) return Promise.resolve();
2460
+ if (imageElement.complete) return Promise.reject(new Error('Image could not be loaded while restoring state'));
2208
2461
  return new Promise((resolve, reject) => {
2209
2462
  let isSettled = false;
2210
- const timerId = setTimeout(() => {
2211
- settle(() => reject(new Error('Image load timed out while restoring state')));
2212
- }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2463
+ let timerId;
2213
2464
  const settle = (callback) => {
2214
2465
  if (isSettled) return;
2215
2466
  isSettled = true;
@@ -2223,8 +2474,21 @@ function ensureFabric() {
2223
2474
  }
2224
2475
  callback();
2225
2476
  };
2226
- const handleLoad = () => settle(resolve);
2227
- const handleError = (error) => settle(() => reject(error));
2477
+ const handleLoad = () => {
2478
+ const didLoad = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) &&
2479
+ (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2480
+ settle(() => {
2481
+ if (didLoad) {
2482
+ resolve();
2483
+ } else {
2484
+ reject(new Error('Image could not be loaded while restoring state'));
2485
+ }
2486
+ });
2487
+ };
2488
+ const handleError = (error) => settle(() => reject(error instanceof Error ? error : new Error('Image could not be loaded while restoring state')));
2489
+ timerId = setTimeout(() => {
2490
+ settle(() => reject(new Error('Image load timed out while restoring state')));
2491
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2228
2492
  if (typeof imageElement.addEventListener === 'function') {
2229
2493
  imageElement.addEventListener('load', handleLoad, { once: true });
2230
2494
  imageElement.addEventListener('error', handleError, { once: true });
@@ -2333,12 +2597,7 @@ function ensureFabric() {
2333
2597
 
2334
2598
  _rebindMaskEvents(mask) {
2335
2599
  if (!mask) return;
2336
- if (mask.__imageEditorMaskHandlers) {
2337
- try {
2338
- mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
2339
- mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
2340
- } catch (error) { void error; }
2341
- }
2600
+ this._cleanupMaskEvents(mask);
2342
2601
 
2343
2602
  const metadata = {};
2344
2603
  if (!Number.isFinite(Number(mask.originalAlpha))) {
@@ -2369,6 +2628,19 @@ function ensureFabric() {
2369
2628
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2370
2629
  }
2371
2630
 
2631
+ _cleanupMaskEvents(mask) {
2632
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2633
+ try {
2634
+ if (typeof mask.off === 'function') {
2635
+ mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
2636
+ mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
2637
+ }
2638
+ } catch (error) {
2639
+ this._reportWarning('Mask event cleanup failed', error);
2640
+ }
2641
+ try { delete mask.__imageEditorMaskHandlers; } catch (error) { this._reportWarning('Mask event metadata cleanup failed', error); }
2642
+ }
2643
+
2372
2644
  /**
2373
2645
  * Creates a mask and adds it to the canvas.
2374
2646
  *
@@ -2517,6 +2789,11 @@ function ensureFabric() {
2517
2789
  }
2518
2790
  }
2519
2791
 
2792
+ if (!mask || typeof mask.set !== 'function' || typeof mask.setCoords !== 'function') {
2793
+ this._reportWarning('fabricGenerator returned an invalid Fabric object');
2794
+ return null;
2795
+ }
2796
+
2520
2797
  const styles = maskConfig.styles || {};
2521
2798
  const hasStyle = property => Object.prototype.hasOwnProperty.call(styles, property);
2522
2799
  const maskSettings = {
@@ -2594,6 +2871,7 @@ function ensureFabric() {
2594
2871
  this.canvas.discardActiveObject();
2595
2872
  selectedMasks.forEach(mask => {
2596
2873
  this._removeLabelForMask(mask);
2874
+ this._cleanupMaskEvents(mask);
2597
2875
  this.canvas.remove(mask);
2598
2876
  });
2599
2877
 
@@ -2620,7 +2898,10 @@ function ensureFabric() {
2620
2898
  const saveHistory = options.saveHistory !== false;
2621
2899
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2622
2900
  masks.forEach(mask => this._removeLabelForMask(mask));
2623
- masks.forEach(mask => this.canvas.remove(mask));
2901
+ masks.forEach(mask => {
2902
+ this._cleanupMaskEvents(mask);
2903
+ this.canvas.remove(mask);
2904
+ });
2624
2905
  this.canvas.discardActiveObject();
2625
2906
  this._lastMask = null;
2626
2907
  this._lastMaskInitialLeft = null;
@@ -2651,6 +2932,101 @@ function ensureFabric() {
2651
2932
  }
2652
2933
  }
2653
2934
 
2935
+ _captureMaskLabelBackups(masks) {
2936
+ if (!this.canvas) return [];
2937
+ const canvasObjects = new Set(this.canvas.getObjects());
2938
+ return (masks || []).map(mask => {
2939
+ const label = mask && mask.__label ? mask.__label : null;
2940
+ return {
2941
+ mask,
2942
+ label,
2943
+ hadLabel: !!label,
2944
+ labelInCanvas: !!label && canvasObjects.has(label),
2945
+ visible: label ? label.visible : undefined
2946
+ };
2947
+ });
2948
+ }
2949
+
2950
+ _restoreMaskLabelBackups(labelBackups) {
2951
+ if (!this.canvas || !Array.isArray(labelBackups)) return;
2952
+ const canvasObjects = new Set(this.canvas.getObjects());
2953
+ labelBackups.forEach(backup => {
2954
+ if (!backup || !backup.mask) return;
2955
+ try {
2956
+ if (!backup.hadLabel) {
2957
+ if (backup.mask.__label) this._removeLabelForMask(backup.mask);
2958
+ return;
2959
+ }
2960
+ backup.mask.__label = backup.label;
2961
+ if (!backup.label) return;
2962
+ if (backup.labelInCanvas && !canvasObjects.has(backup.label)) {
2963
+ this.canvas.add(backup.label);
2964
+ canvasObjects.add(backup.label);
2965
+ }
2966
+ if (backup.visible !== undefined) backup.label.set({ visible: backup.visible });
2967
+ if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2968
+ this._syncMaskLabel(backup.mask);
2969
+ } catch (error) {
2970
+ this._reportWarning('restoreMaskLabelBackups: failed to restore mask label', error);
2971
+ }
2972
+ });
2973
+ }
2974
+
2975
+ _captureActiveObjectBackup() {
2976
+ if (!this.canvas) return null;
2977
+ const activeObject = this.canvas.getActiveObject();
2978
+ if (!activeObject) return null;
2979
+ const selectedObjects = typeof activeObject.getObjects === 'function'
2980
+ ? activeObject.getObjects()
2981
+ : [activeObject];
2982
+ return { activeObject, selectedObjects };
2983
+ }
2984
+
2985
+ _restoreActiveObjectBackup(activeObjectBackup) {
2986
+ if (!this.canvas || !activeObjectBackup || !activeObjectBackup.activeObject) return;
2987
+ const canvasObjects = this.canvas.getObjects();
2988
+ const selectedObjects = Array.isArray(activeObjectBackup.selectedObjects)
2989
+ ? activeObjectBackup.selectedObjects
2990
+ : [];
2991
+ const canRestore = selectedObjects.length
2992
+ ? selectedObjects.every(object => canvasObjects.includes(object))
2993
+ : canvasObjects.includes(activeObjectBackup.activeObject);
2994
+ if (!canRestore) return;
2995
+ try {
2996
+ this.canvas.setActiveObject(activeObjectBackup.activeObject);
2997
+ } catch (error) { void error; }
2998
+ }
2999
+
3000
+ _captureMaskExportBackups(masks) {
3001
+ return (masks || []).map(mask => ({
3002
+ object: mask,
3003
+ visible: mask.visible,
3004
+ opacity: mask.opacity,
3005
+ fill: mask.fill,
3006
+ strokeWidth: mask.strokeWidth,
3007
+ stroke: mask.stroke,
3008
+ selectable: mask.selectable,
3009
+ lockRotation: mask.lockRotation
3010
+ }));
3011
+ }
3012
+
3013
+ _restoreMaskExportBackups(maskBackups) {
3014
+ (maskBackups || []).forEach(backup => {
3015
+ try {
3016
+ backup.object.set({
3017
+ visible: backup.visible,
3018
+ opacity: backup.opacity,
3019
+ fill: backup.fill,
3020
+ strokeWidth: backup.strokeWidth,
3021
+ stroke: backup.stroke,
3022
+ selectable: backup.selectable,
3023
+ lockRotation: backup.lockRotation
3024
+ });
3025
+ backup.object.setCoords();
3026
+ } catch (error) { void error; }
3027
+ });
3028
+ }
3029
+
2654
3030
  /**
2655
3031
  * Returns a stable zero-based creation index for label callbacks.
2656
3032
  *
@@ -2728,10 +3104,13 @@ function ensureFabric() {
2728
3104
  _hideAllMaskLabels() {
2729
3105
  if (!this.canvas) return;
2730
3106
  const canvasObjects = this.canvas.getObjects();
3107
+ const canvasObjectSet = new Set(canvasObjects);
2731
3108
  const labels = canvasObjects.filter(object => object.maskLabel);
2732
3109
  labels.forEach(label => {
2733
3110
  try {
2734
- if (canvasObjects.includes(label)) this.canvas.remove(label);
3111
+ if (canvasObjectSet.has(label)) {
3112
+ this.canvas.remove(label);
3113
+ }
2735
3114
  } catch (error) { void error; }
2736
3115
  });
2737
3116
  canvasObjects.forEach(object => {
@@ -2908,6 +3287,9 @@ function ensureFabric() {
2908
3287
  fileType: 'png'
2909
3288
  }));
2910
3289
  this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
3290
+ if (this.canvas.getObjects().some(object => object.maskId)) {
3291
+ throw new Error('Masks could not be removed during merge');
3292
+ }
2911
3293
  await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2912
3294
  preserveScroll: true,
2913
3295
  resetMaskCounter: false
@@ -2987,7 +3369,11 @@ function ensureFabric() {
2987
3369
 
2988
3370
  if (!exportImageArea) {
2989
3371
  const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
3372
+ const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
2990
3373
  const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
3374
+ const maskStyleBackups = this._captureMaskExportBackups(editableMasks);
3375
+ const labelBackups = this._captureMaskLabelBackups(editableMasks);
3376
+ const activeObjectBackup = this._captureActiveObjectBackup();
2991
3377
 
2992
3378
  try {
2993
3379
  masks.forEach(mask => { mask.set({ visible: false }); });
@@ -3008,23 +3394,19 @@ function ensureFabric() {
3008
3394
  maskVisibilityBackups.forEach(backup => {
3009
3395
  try { backup.object.set({ visible: backup.visible }); } catch (error) { void error; }
3010
3396
  });
3397
+ this._restoreMaskExportBackups(maskStyleBackups);
3398
+ this._restoreMaskLabelBackups(labelBackups);
3399
+ this._restoreActiveObjectBackup(activeObjectBackup);
3011
3400
  this.canvas.renderAll();
3012
3401
  }
3013
3402
  }
3014
3403
 
3015
3404
  // Render masks as export shapes without mutating their editable styles.
3016
3405
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3017
- const maskStyleBackups = masks.map(mask => ({
3018
- object: mask,
3019
- opacity: mask.opacity,
3020
- fill: mask.fill,
3021
- strokeWidth: mask.strokeWidth,
3022
- stroke: mask.stroke,
3023
- selectable: mask.selectable,
3024
- lockRotation: mask.lockRotation
3025
- }));
3406
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3407
+ const labelBackups = this._captureMaskLabelBackups(masks);
3408
+ const activeObjectBackup = this._captureActiveObjectBackup();
3026
3409
 
3027
- let finalBase64;
3028
3410
  try {
3029
3411
  // Labels are UI overlays and should not be part of the flattened export.
3030
3412
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -3043,8 +3425,7 @@ function ensureFabric() {
3043
3425
  const imageBounds = this.originalImage.getBoundingRect(true, true);
3044
3426
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
3045
3427
 
3046
- // Crop precisely in offscreen canvas
3047
- finalBase64 = await this._exportCanvasRegionToDataURL({
3428
+ return await this._exportCanvasRegionToDataURL({
3048
3429
  ...exportRegion,
3049
3430
  multiplier,
3050
3431
  quality,
@@ -3052,24 +3433,11 @@ function ensureFabric() {
3052
3433
  sealPartialEdges: this._getPartialExportEdges(imageBounds)
3053
3434
  });
3054
3435
  } finally {
3055
- maskStyleBackups.forEach(backup => {
3056
- try {
3057
- backup.object.set({
3058
- opacity: backup.opacity,
3059
- fill: backup.fill,
3060
- strokeWidth: backup.strokeWidth,
3061
- stroke: backup.stroke,
3062
- selectable: backup.selectable,
3063
- lockRotation: backup.lockRotation
3064
- });
3065
- backup.object.setCoords();
3066
- } catch (error) { void error; }
3067
- });
3068
-
3436
+ this._restoreMaskExportBackups(maskStyleBackups);
3437
+ this._restoreMaskLabelBackups(labelBackups);
3438
+ this._restoreActiveObjectBackup(activeObjectBackup);
3069
3439
  this.canvas.renderAll();
3070
3440
  }
3071
-
3072
- return finalBase64;
3073
3441
  }
3074
3442
 
3075
3443
  /**
@@ -3158,13 +3526,8 @@ function ensureFabric() {
3158
3526
  }
3159
3527
 
3160
3528
  // Convert the final data URL to a File with the requested MIME type.
3161
- const binaryString = atob(imageDataUrl.split(',')[1]);
3529
+ const bytes = this._decodeBase64Payload(imageDataUrl.split(',')[1]);
3162
3530
  const mime = `image/${safeFileType}`;
3163
- let byteIndex = binaryString.length;
3164
- const bytes = new Uint8Array(byteIndex);
3165
- while (byteIndex--) {
3166
- bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
3167
- }
3168
3531
  return new File([bytes], fileName, { type: mime });
3169
3532
  }
3170
3533
 
@@ -3214,20 +3577,21 @@ function ensureFabric() {
3214
3577
  }
3215
3578
 
3216
3579
  _removeCropRect() {
3217
- if (!this._cropRect) return;
3218
- try {
3219
- if (this._cropHandlers && this._cropHandlers.length) {
3220
- this._cropHandlers.forEach(targetHandlers => {
3221
- targetHandlers.handlers.forEach(handlerRecord => {
3580
+ if (this._cropHandlers && this._cropHandlers.length) {
3581
+ this._cropHandlers.forEach(targetHandlers => {
3582
+ (targetHandlers.handlers || []).forEach(handlerRecord => {
3583
+ try {
3222
3584
  if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
3223
3585
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3224
3586
  }
3225
- });
3587
+ } catch (error) {
3588
+ this._reportWarning('Crop handler cleanup failed', error);
3589
+ }
3226
3590
  });
3227
- }
3228
- } catch (error) { void error; }
3591
+ });
3592
+ }
3229
3593
 
3230
- try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
3594
+ try { if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect); } catch (error) { void error; }
3231
3595
  this._cropRect = null;
3232
3596
  this._cropHandlers = [];
3233
3597
  }
@@ -3325,6 +3689,30 @@ function ensureFabric() {
3325
3689
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3326
3690
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3327
3691
  cropRect.setCoords();
3692
+ const cropBounds = cropRect.getBoundingRect(true, true);
3693
+ const imageLeft = Number(imageBounds.left) || 0;
3694
+ const imageTop = Number(imageBounds.top) || 0;
3695
+ const imageRight = imageLeft + (Number(imageBounds.width) || 0);
3696
+ const imageBottom = imageTop + (Number(imageBounds.height) || 0);
3697
+ let deltaX = 0;
3698
+ let deltaY = 0;
3699
+ if (cropBounds.left < imageLeft) {
3700
+ deltaX = imageLeft - cropBounds.left;
3701
+ } else if (cropBounds.left + cropBounds.width > imageRight) {
3702
+ deltaX = imageRight - (cropBounds.left + cropBounds.width);
3703
+ }
3704
+ if (cropBounds.top < imageTop) {
3705
+ deltaY = imageTop - cropBounds.top;
3706
+ } else if (cropBounds.top + cropBounds.height > imageBottom) {
3707
+ deltaY = imageBottom - (cropBounds.top + cropBounds.height);
3708
+ }
3709
+ if (deltaX || deltaY) {
3710
+ cropRect.set({
3711
+ left: (Number(cropRect.left) || 0) + deltaX,
3712
+ top: (Number(cropRect.top) || 0) + deltaY
3713
+ });
3714
+ cropRect.setCoords();
3715
+ }
3328
3716
  this.canvas.requestRenderAll();
3329
3717
  } catch (error) { void error; }
3330
3718
  };
@@ -3394,9 +3782,13 @@ function ensureFabric() {
3394
3782
  try {
3395
3783
  beforeJson = this._serializeCanvasState();
3396
3784
  } catch (error) {
3397
- this._reportWarning('applyCrop: could not serialize before state', error);
3785
+ this._reportError('applyCrop: failed to capture rollback state', error);
3398
3786
  beforeJson = null;
3399
3787
  }
3788
+ if (!beforeJson) {
3789
+ this.cancelCrop();
3790
+ return;
3791
+ }
3400
3792
 
3401
3793
  const preservedMasks = [];
3402
3794
 
@@ -3404,23 +3796,20 @@ function ensureFabric() {
3404
3796
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3405
3797
  if (masks && masks.length) {
3406
3798
  masks.forEach(mask => {
3407
- try {
3408
- mask.setCoords();
3409
- const maskBounds = mask.getBoundingRect(true, true);
3410
- const intersectsCrop =
3411
- maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
3412
- maskBounds.left + maskBounds.width > cropRegion.sourceX &&
3413
- maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3414
- maskBounds.top + maskBounds.height > cropRegion.sourceY;
3415
- this._removeLabelForMask(mask);
3416
- this.canvas.remove(mask);
3417
- if (shouldPreserveMasks && intersectsCrop) {
3418
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3419
- mask.set({ visible: true });
3420
- preservedMasks.push(mask);
3421
- }
3422
- } catch (error) {
3423
- this._reportWarning('applyCrop: failed to remove mask', error);
3799
+ mask.setCoords();
3800
+ const maskBounds = mask.getBoundingRect(true, true);
3801
+ const intersectsCrop =
3802
+ maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
3803
+ maskBounds.left + maskBounds.width > cropRegion.sourceX &&
3804
+ maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3805
+ maskBounds.top + maskBounds.height > cropRegion.sourceY;
3806
+ this._removeLabelForMask(mask);
3807
+ this._cleanupMaskEvents(mask);
3808
+ this.canvas.remove(mask);
3809
+ if (shouldPreserveMasks && intersectsCrop) {
3810
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3811
+ mask.set({ visible: true });
3812
+ preservedMasks.push(mask);
3424
3813
  }
3425
3814
  });
3426
3815
  this._clearMaskPlacementMemory();
@@ -3428,7 +3817,8 @@ function ensureFabric() {
3428
3817
  this.canvas.renderAll();
3429
3818
  }
3430
3819
  } catch (error) {
3431
- this._reportWarning('applyCrop: error while removing masks', error);
3820
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
3821
+ return;
3432
3822
  }
3433
3823
 
3434
3824
  this._removeCropRect();
@@ -3500,7 +3890,7 @@ function ensureFabric() {
3500
3890
  * @private
3501
3891
  */
3502
3892
  _updateInputs() {
3503
- const scaleInputElement = this._getElement('scaleRate');
3893
+ const scaleInputElement = this._getElement('scalePercentageInput');
3504
3894
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3505
3895
  }
3506
3896
 
@@ -3520,14 +3910,14 @@ function ensureFabric() {
3520
3910
  const canUndo = this.historyManager?.canUndo();
3521
3911
  const canRedo = this.historyManager?.canRedo();
3522
3912
  const isInCropMode = !!this._cropMode;
3523
- const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
3913
+ const isBusy = this.isBusy();
3524
3914
 
3525
3915
  if (isInCropMode) {
3526
3916
  // Disable all controls except the crop action buttons while crop mode is active.
3527
3917
  for (const key of Object.keys(this.elements || {})) {
3528
3918
  const element = this._getElement(key);
3529
3919
  if (!element) continue;
3530
- if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
3920
+ if (key === 'applyCropButton' || key === 'cancelCropButton' || key === 'applyCropBtn' || key === 'cancelCropBtn') {
3531
3921
  this._setDisabled(key, false);
3532
3922
  } else {
3533
3923
  this._setDisabled(key, true);
@@ -3536,21 +3926,25 @@ function ensureFabric() {
3536
3926
  return;
3537
3927
  }
3538
3928
 
3539
- this._setDisabled('zoomInBtn', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3540
- this._setDisabled('zoomOutBtn', !hasImage || isBusy || this.currentScale <= this.options.minScale);
3541
- this._setDisabled('rotateLeftBtn', !hasImage || isBusy);
3542
- this._setDisabled('rotateRightBtn', !hasImage || isBusy);
3543
- this._setDisabled('addMaskBtn', !hasImage || isBusy);
3544
- this._setDisabled('removeMaskBtn', !hasSelectedMask || isBusy);
3545
- this._setDisabled('removeAllMasksBtn', !hasMasks || isBusy);
3546
- this._setDisabled('mergeBtn', !hasImage || !hasMasks || isBusy);
3547
- this._setDisabled('downloadBtn', !hasImage || isBusy);
3548
- this._setDisabled('resetBtn', !hasImage || isDefaultTransform || isBusy);
3549
- this._setDisabled('undoBtn', !hasImage || isBusy || !canUndo);
3550
- this._setDisabled('redoBtn', !hasImage || isBusy || !canRedo);
3551
- this._setDisabled('cropBtn', !hasImage || isBusy);
3552
- this._setDisabled('applyCropBtn', true);
3553
- this._setDisabled('cancelCropBtn', true);
3929
+ this._setDisabled('zoomInButton', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3930
+ this._setDisabled('zoomOutButton', !hasImage || isBusy || this.currentScale <= this.options.minScale);
3931
+ this._setDisabled('rotateLeftButton', !hasImage || isBusy);
3932
+ this._setDisabled('rotateRightButton', !hasImage || isBusy);
3933
+ this._setDisabled('createMaskButton', !hasImage || isBusy);
3934
+ this._setDisabled('removeSelectedMaskButton', !hasSelectedMask || isBusy);
3935
+ this._setDisabled('removeAllMasksButton', !hasMasks || isBusy);
3936
+ this._setDisabled('mergeMasksButton', !hasImage || !hasMasks || isBusy);
3937
+ this._setDisabled('downloadImageButton', !hasImage || isBusy);
3938
+ this._setDisabled('resetImageTransformButton', !hasImage || isDefaultTransform || isBusy);
3939
+ this._setDisabled('undoButton', !hasImage || isBusy || !canUndo);
3940
+ this._setDisabled('redoButton', !hasImage || isBusy || !canRedo);
3941
+ this._setDisabled('enterCropModeButton', !hasImage || isBusy);
3942
+ this._setDisabled('applyCropButton', true);
3943
+ this._setDisabled('cancelCropButton', true);
3944
+ this._setDisabled('scalePercentageInput', !hasImage || isBusy);
3945
+ this._setDisabled('rotateLeftDegreesInput', !hasImage || isBusy);
3946
+ this._setDisabled('rotateRightDegreesInput', !hasImage || isBusy);
3947
+ this._setDisabled('maskList', !hasImage || isBusy);
3554
3948
  this._setDisabled('imageInput', isBusy);
3555
3949
  this._setDisabled('uploadArea', isBusy);
3556
3950
  }
@@ -3558,7 +3952,7 @@ function ensureFabric() {
3558
3952
  /**
3559
3953
  * Enables or disables a specific UI element (typically a button) by its key.
3560
3954
  *
3561
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
3955
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3562
3956
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3563
3957
  * @private
3564
3958
  */
@@ -3693,10 +4087,7 @@ function ensureFabric() {
3693
4087
  }
3694
4088
  } catch (error) { void error; }
3695
4089
 
3696
- if (this._cropRect) {
3697
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3698
- this._cropRect = null;
3699
- }
4090
+ if (this._cropRect) this._removeCropRect();
3700
4091
 
3701
4092
  if (this.containerElement && this._containerOriginalOverflow) {
3702
4093
  try { this._restoreContainerOverflowState(); } catch (error) { void error; }
@@ -3716,10 +4107,16 @@ function ensureFabric() {
3716
4107
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3717
4108
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3718
4109
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
4110
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3719
4111
  } catch (error) { void error; }
3720
4112
  }
3721
4113
 
3722
4114
  if (this.canvas) {
4115
+ try {
4116
+ this.canvas.getObjects().forEach(object => {
4117
+ if (object && object.maskId) this._cleanupMaskEvents(object);
4118
+ });
4119
+ } catch (error) { void error; }
3723
4120
  try { this.canvas.dispose(); } catch (error) { void error; }
3724
4121
  this.canvas = null;
3725
4122
  this.canvasElement = null;
@@ -3861,7 +4258,7 @@ function ensureFabric() {
3861
4258
  task.reject(error);
3862
4259
  }
3863
4260
  } finally {
3864
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
4261
+ if (this.currentTask === task) this.currentTask = null;
3865
4262
  }
3866
4263
  }
3867
4264
  } finally {
@@ -3940,9 +4337,9 @@ function ensureFabric() {
3940
4337
  execute(command) {
3941
4338
  const result = command.execute();
3942
4339
  if (result && typeof result.then === 'function') {
3943
- return Promise.resolve(result).then(() => {
4340
+ return this.enqueue(() => Promise.resolve(result).then(() => {
3944
4341
  this.push(command);
3945
- });
4342
+ }));
3946
4343
  }
3947
4344
  this.push(command);
3948
4345
  return result;