@bensitu/image-editor 1.4.2 → 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,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.0
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -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
 
@@ -860,9 +955,7 @@ function ensureFabric() {
860
955
  this.canvas.renderAll();
861
956
  this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
862
957
 
863
- if (typeof this.onImageLoaded === 'function') {
864
- this.onImageLoaded();
865
- }
958
+ this._notifyImageLoaded();
866
959
  } catch (error) {
867
960
  await this._rollbackLoadImageTransaction(transaction);
868
961
  throw error;
@@ -929,7 +1022,7 @@ function ensureFabric() {
929
1022
  };
930
1023
  timerId = setTimeout(() => {
931
1024
  settle(() => reject(new Error('Image load timed out')));
932
- try { imageElement.src = ''; } catch (error) { void error; }
1025
+ try { imageElement.src = ''; } catch (error) { this._reportWarning('Image timeout cleanup failed', error); }
933
1026
  }, safeTimeoutMs);
934
1027
  imageElement.onload = () => settle(() => resolve(imageElement));
935
1028
  imageElement.onerror = (error) => settle(() => reject(error));
@@ -1002,6 +1095,7 @@ function ensureFabric() {
1002
1095
  async _rollbackLoadImageTransaction(transaction) {
1003
1096
  if (!transaction || !this.canvas || this._disposed) return;
1004
1097
  let didRestoreCanvasState = false;
1098
+ let didFailCanvasRestore = false;
1005
1099
  try {
1006
1100
  if (transaction.canvasState) {
1007
1101
  await this.loadFromState(transaction.canvasState);
@@ -1009,23 +1103,28 @@ function ensureFabric() {
1009
1103
  }
1010
1104
  } catch (error) {
1011
1105
  this._lastMask = null;
1106
+ didFailCanvasRestore = true;
1012
1107
  this._reportError('loadImage rollback failed', error);
1013
1108
  }
1014
1109
 
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);
1110
+ if (didFailCanvasRestore) {
1111
+ this._reconcileEditorStateFromCanvas();
1023
1112
  } else {
1024
- 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;
1025
1127
  }
1026
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1027
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1028
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1029
1128
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
1030
1129
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
1031
1130
  if (this.containerElement) {
@@ -1039,6 +1138,49 @@ function ensureFabric() {
1039
1138
  if (this.canvas) this.canvas.renderAll();
1040
1139
  }
1041
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
+
1042
1184
  _restoreLastMaskReference(previousLastMask) {
1043
1185
  if (!this.canvas) {
1044
1186
  this._lastMask = null;
@@ -1116,6 +1258,7 @@ function ensureFabric() {
1116
1258
  * @private
1117
1259
  */
1118
1260
  _setCanvasSizeInt(width, height) {
1261
+ if (!this.canvas) return;
1119
1262
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
1120
1263
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
1121
1264
  // Set fabric internal and also style attributes to keep DOM consistent
@@ -1427,7 +1570,7 @@ function ensureFabric() {
1427
1570
  /**
1428
1571
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1429
1572
  *
1430
- * @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.
1431
1574
  * @private
1432
1575
  */
1433
1576
  _serializeEditorMetadata() {
@@ -1435,13 +1578,17 @@ function ensureFabric() {
1435
1578
  const currentScale = Number(this.currentScale);
1436
1579
  const currentRotation = Number(this.currentRotation);
1437
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;
1438
1583
 
1439
1584
  return {
1440
1585
  version: 1,
1441
1586
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1442
1587
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1443
1588
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1444
- 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
1445
1592
  };
1446
1593
  }
1447
1594
 
@@ -1813,19 +1960,14 @@ function ensureFabric() {
1813
1960
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1814
1961
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1815
1962
  });
1816
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1817
-
1818
1963
  let minWidth = 0;
1819
1964
  let minHeight = 0;
1820
- if (shouldUseScrollSafeViewport) {
1965
+ if (this.containerElement) {
1821
1966
  const viewport = this._getContainerViewportSize();
1822
1967
  const safetyMargin = this._getScrollSafetyMargin();
1823
1968
 
1824
1969
  minWidth = Math.max(1, viewport.width - safetyMargin);
1825
1970
  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
1971
  }
1830
1972
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1831
1973
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1904,9 +2046,16 @@ function ensureFabric() {
1904
2046
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1905
2047
  }
1906
2048
 
2049
+ _isCropModeAllowedOperation(operationName) {
2050
+ return operationName === 'applyCrop' || operationName === 'cancelCrop';
2051
+ }
2052
+
1907
2053
  _assertIdleForOperation(operationName, options = {}) {
1908
2054
  this._assertEditorAvailable(operationName);
1909
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
+ }
1910
2059
  if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1911
2060
  throw new Error(`${operationName} cannot run while an animation is running`);
1912
2061
  }
@@ -1920,10 +2069,14 @@ function ensureFabric() {
1920
2069
 
1921
2070
  _assertCanQueueAnimation(operationName, options = {}) {
1922
2071
  this._assertEditorAvailable(operationName);
1923
- 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) {
1924
2077
  throw new Error(`${operationName} cannot run while an image is loading`);
1925
2078
  }
1926
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
2079
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1927
2080
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1928
2081
  }
1929
2082
  }
@@ -2130,10 +2283,19 @@ function ensureFabric() {
2130
2283
 
2131
2284
  return this.animationQueue.add(async () => {
2132
2285
  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);
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
+ }
2137
2299
  }).finally(() => {
2138
2300
  if (!this._disposed && this.canvas) this._updateUI();
2139
2301
  }).catch(error => {
@@ -2177,6 +2339,13 @@ function ensureFabric() {
2177
2339
  ? JSON.parse(serializedState)
2178
2340
  : serializedState;
2179
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;
2180
2349
  if (
2181
2350
  editorMetadata &&
2182
2351
  Object.prototype.hasOwnProperty.call(editorMetadata, 'version') &&
@@ -2185,7 +2354,7 @@ function ensureFabric() {
2185
2354
  this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
2186
2355
  }
2187
2356
 
2188
- this.canvas.loadFromJSON(state, async () => {
2357
+ const finishLoad = async () => {
2189
2358
  try {
2190
2359
  if (this._disposed || !this.canvas) {
2191
2360
  reject(new Error('Editor was disposed while loading state'));
@@ -2228,6 +2397,12 @@ function ensureFabric() {
2228
2397
  this.currentRotation = 0;
2229
2398
  }
2230
2399
 
2400
+ if (hasRestoredCanvasSize) {
2401
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2402
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2403
+ this._updateCanvasSizeToImageBounds();
2404
+ }
2405
+
2231
2406
  const masks = canvasObjects.filter(object => object.maskId);
2232
2407
  masks.forEach(mask => {
2233
2408
  this._restoreMaskControls(mask);
@@ -2259,7 +2434,9 @@ function ensureFabric() {
2259
2434
  this._reportError('loadFromState() failed', callbackError);
2260
2435
  reject(callbackError);
2261
2436
  }
2262
- });
2437
+ };
2438
+
2439
+ this.canvas.loadFromJSON(state, () => { void finishLoad(); });
2263
2440
 
2264
2441
  } catch (error) {
2265
2442
  this._reportError('loadFromState() failed', error);
@@ -2420,12 +2597,7 @@ function ensureFabric() {
2420
2597
 
2421
2598
  _rebindMaskEvents(mask) {
2422
2599
  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
- }
2600
+ this._cleanupMaskEvents(mask);
2429
2601
 
2430
2602
  const metadata = {};
2431
2603
  if (!Number.isFinite(Number(mask.originalAlpha))) {
@@ -2456,6 +2628,19 @@ function ensureFabric() {
2456
2628
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2457
2629
  }
2458
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
+
2459
2644
  /**
2460
2645
  * Creates a mask and adds it to the canvas.
2461
2646
  *
@@ -2686,6 +2871,7 @@ function ensureFabric() {
2686
2871
  this.canvas.discardActiveObject();
2687
2872
  selectedMasks.forEach(mask => {
2688
2873
  this._removeLabelForMask(mask);
2874
+ this._cleanupMaskEvents(mask);
2689
2875
  this.canvas.remove(mask);
2690
2876
  });
2691
2877
 
@@ -2712,7 +2898,10 @@ function ensureFabric() {
2712
2898
  const saveHistory = options.saveHistory !== false;
2713
2899
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2714
2900
  masks.forEach(mask => this._removeLabelForMask(mask));
2715
- masks.forEach(mask => this.canvas.remove(mask));
2901
+ masks.forEach(mask => {
2902
+ this._cleanupMaskEvents(mask);
2903
+ this.canvas.remove(mask);
2904
+ });
2716
2905
  this.canvas.discardActiveObject();
2717
2906
  this._lastMask = null;
2718
2907
  this._lastMaskInitialLeft = null;
@@ -2777,7 +2966,9 @@ function ensureFabric() {
2777
2966
  if (backup.visible !== undefined) backup.label.set({ visible: backup.visible });
2778
2967
  if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2779
2968
  this._syncMaskLabel(backup.mask);
2780
- } catch (error) { void error; }
2969
+ } catch (error) {
2970
+ this._reportWarning('restoreMaskLabelBackups: failed to restore mask label', error);
2971
+ }
2781
2972
  });
2782
2973
  }
2783
2974
 
@@ -2919,7 +3110,6 @@ function ensureFabric() {
2919
3110
  try {
2920
3111
  if (canvasObjectSet.has(label)) {
2921
3112
  this.canvas.remove(label);
2922
- canvasObjectSet.delete(label);
2923
3113
  }
2924
3114
  } catch (error) { void error; }
2925
3115
  });
@@ -3217,7 +3407,6 @@ function ensureFabric() {
3217
3407
  const labelBackups = this._captureMaskLabelBackups(masks);
3218
3408
  const activeObjectBackup = this._captureActiveObjectBackup();
3219
3409
 
3220
- let finalBase64;
3221
3410
  try {
3222
3411
  // Labels are UI overlays and should not be part of the flattened export.
3223
3412
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -3236,8 +3425,7 @@ function ensureFabric() {
3236
3425
  const imageBounds = this.originalImage.getBoundingRect(true, true);
3237
3426
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
3238
3427
 
3239
- // Crop precisely in offscreen canvas
3240
- finalBase64 = await this._exportCanvasRegionToDataURL({
3428
+ return await this._exportCanvasRegionToDataURL({
3241
3429
  ...exportRegion,
3242
3430
  multiplier,
3243
3431
  quality,
@@ -3250,8 +3438,6 @@ function ensureFabric() {
3250
3438
  this._restoreActiveObjectBackup(activeObjectBackup);
3251
3439
  this.canvas.renderAll();
3252
3440
  }
3253
-
3254
- return finalBase64;
3255
3441
  }
3256
3442
 
3257
3443
  /**
@@ -3391,20 +3577,21 @@ function ensureFabric() {
3391
3577
  }
3392
3578
 
3393
3579
  _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 => {
3580
+ if (this._cropHandlers && this._cropHandlers.length) {
3581
+ this._cropHandlers.forEach(targetHandlers => {
3582
+ (targetHandlers.handlers || []).forEach(handlerRecord => {
3583
+ try {
3399
3584
  if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
3400
3585
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3401
3586
  }
3402
- });
3587
+ } catch (error) {
3588
+ this._reportWarning('Crop handler cleanup failed', error);
3589
+ }
3403
3590
  });
3404
- }
3405
- } catch (error) { void error; }
3591
+ });
3592
+ }
3406
3593
 
3407
- 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; }
3408
3595
  this._cropRect = null;
3409
3596
  this._cropHandlers = [];
3410
3597
  }
@@ -3595,9 +3782,13 @@ function ensureFabric() {
3595
3782
  try {
3596
3783
  beforeJson = this._serializeCanvasState();
3597
3784
  } catch (error) {
3598
- this._reportWarning('applyCrop: could not serialize before state', error);
3785
+ this._reportError('applyCrop: failed to capture rollback state', error);
3599
3786
  beforeJson = null;
3600
3787
  }
3788
+ if (!beforeJson) {
3789
+ this.cancelCrop();
3790
+ return;
3791
+ }
3601
3792
 
3602
3793
  const preservedMasks = [];
3603
3794
 
@@ -3613,6 +3804,7 @@ function ensureFabric() {
3613
3804
  maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3614
3805
  maskBounds.top + maskBounds.height > cropRegion.sourceY;
3615
3806
  this._removeLabelForMask(mask);
3807
+ this._cleanupMaskEvents(mask);
3616
3808
  this.canvas.remove(mask);
3617
3809
  if (shouldPreserveMasks && intersectsCrop) {
3618
3810
  this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
@@ -3698,7 +3890,7 @@ function ensureFabric() {
3698
3890
  * @private
3699
3891
  */
3700
3892
  _updateInputs() {
3701
- const scaleInputElement = this._getElement('scaleRate');
3893
+ const scaleInputElement = this._getElement('scalePercentageInput');
3702
3894
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3703
3895
  }
3704
3896
 
@@ -3725,7 +3917,7 @@ function ensureFabric() {
3725
3917
  for (const key of Object.keys(this.elements || {})) {
3726
3918
  const element = this._getElement(key);
3727
3919
  if (!element) continue;
3728
- if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
3920
+ if (key === 'applyCropButton' || key === 'cancelCropButton' || key === 'applyCropBtn' || key === 'cancelCropBtn') {
3729
3921
  this._setDisabled(key, false);
3730
3922
  } else {
3731
3923
  this._setDisabled(key, true);
@@ -3734,24 +3926,24 @@ function ensureFabric() {
3734
3926
  return;
3735
3927
  }
3736
3928
 
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);
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);
3755
3947
  this._setDisabled('maskList', !hasImage || isBusy);
3756
3948
  this._setDisabled('imageInput', isBusy);
3757
3949
  this._setDisabled('uploadArea', isBusy);
@@ -3760,7 +3952,7 @@ function ensureFabric() {
3760
3952
  /**
3761
3953
  * Enables or disables a specific UI element (typically a button) by its key.
3762
3954
  *
3763
- * @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').
3764
3956
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3765
3957
  * @private
3766
3958
  */
@@ -3895,10 +4087,7 @@ function ensureFabric() {
3895
4087
  }
3896
4088
  } catch (error) { void error; }
3897
4089
 
3898
- if (this._cropRect) {
3899
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3900
- this._cropRect = null;
3901
- }
4090
+ if (this._cropRect) this._removeCropRect();
3902
4091
 
3903
4092
  if (this.containerElement && this._containerOriginalOverflow) {
3904
4093
  try { this._restoreContainerOverflowState(); } catch (error) { void error; }
@@ -3918,10 +4107,16 @@ function ensureFabric() {
3918
4107
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3919
4108
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3920
4109
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
4110
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3921
4111
  } catch (error) { void error; }
3922
4112
  }
3923
4113
 
3924
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; }
3925
4120
  try { this.canvas.dispose(); } catch (error) { void error; }
3926
4121
  this.canvas = null;
3927
4122
  this.canvasElement = null;
@@ -4063,7 +4258,7 @@ function ensureFabric() {
4063
4258
  task.reject(error);
4064
4259
  }
4065
4260
  } finally {
4066
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
4261
+ if (this.currentTask === task) this.currentTask = null;
4067
4262
  }
4068
4263
  }
4069
4264
  } finally {