@bensitu/image-editor 1.5.0 → 1.5.2

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.5.0
4
+ * @version 1.5.2
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -132,6 +132,8 @@ function ensureFabric() {
132
132
  * @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
133
133
  * @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
134
134
  * @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
135
+ * @param {number} [options.maxExportPixels=50000000] - Maximum output pixels allowed per export.
136
+ * @param {number} [options.maxHistorySize=50] - Maximum undo/redo history entries to keep. Large base64 images can make each snapshot expensive.
135
137
  * @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
136
138
  * @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
137
139
  * @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
@@ -202,6 +204,8 @@ function ensureFabric() {
202
204
  imageLoadTimeoutMs: 30000,
203
205
 
204
206
  exportMultiplier: 1,
207
+ maxExportPixels: 50000000,
208
+ maxHistorySize: 50,
205
209
  exportImageAreaByDefault: true,
206
210
 
207
211
  defaultMaskWidth: 50,
@@ -234,6 +238,7 @@ function ensureFabric() {
234
238
  ...userCrop
235
239
  }
236
240
  };
241
+ this._normalizeOptions();
237
242
 
238
243
  // Verify that Fabric.js is present before any canvas work starts.
239
244
  this._fabricLoaded = !!ensureFabric();
@@ -258,11 +263,12 @@ function ensureFabric() {
258
263
  this._activeOperationToken = null;
259
264
  this.elements = {};
260
265
  this.isImageLoadedToCanvas = false;
261
- this.maxHistorySize = 50;
266
+ this.maxHistorySize = this.options.maxHistorySize;
262
267
 
263
268
  this._handlersByElementKey = {};
264
269
  this._elementCache = {};
265
270
  this._elementOriginalPointerEvents = new Map();
271
+ this._elementOriginalDisabledState = new Map();
266
272
 
267
273
  this._lastMask = null;
268
274
  this._lastMaskInitialLeft = null;
@@ -271,6 +277,7 @@ function ensureFabric() {
271
277
  this._lastSnapshot = null;
272
278
 
273
279
  this._cropMode = false;
280
+ this._isApplyingCrop = false;
274
281
  this._cropRect = null;
275
282
  this._cropHandlers = [];
276
283
  this._cropPrevEvented = null;
@@ -283,6 +290,8 @@ function ensureFabric() {
283
290
  this._activeAnimationRejectors = new Set();
284
291
  this._disposed = false;
285
292
  this._initialized = false;
293
+ this._deprecatedElementKeyWarnings = new Set();
294
+ this._cropRotationWarningEmitted = false;
286
295
 
287
296
  this.onImageLoaded = typeof this.options.onImageLoaded === 'function' ? this.options.onImageLoaded : null;
288
297
 
@@ -356,7 +365,13 @@ function ensureFabric() {
356
365
  * });
357
366
  */
358
367
  init(idMap = {}) {
359
- if (!this._fabricLoaded) return;
368
+ if (!this._fabricLoaded) {
369
+ this._fabricLoaded = !!ensureFabric();
370
+ if (!this._fabricLoaded) {
371
+ this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
372
+ return;
373
+ }
374
+ }
360
375
  if (this._initialized || this.canvas) this.dispose();
361
376
  this._disposed = false;
362
377
  this._initialized = true;
@@ -368,10 +383,11 @@ function ensureFabric() {
368
383
  this._activeOperationName = null;
369
384
  this._activeOperationToken = null;
370
385
  this._elementOriginalPointerEvents = new Map();
386
+ this._elementOriginalDisabledState = new Map();
387
+ this._isApplyingCrop = false;
371
388
  this._containerOriginalOverflow = null;
372
389
  this._lastContainerViewportSize = null;
373
390
  this._canvasElementOriginalStyle = null;
374
- this._deprecatedElementKeyWarnings = new Set();
375
391
 
376
392
  const defaults = {
377
393
  canvas: 'fabricCanvas',
@@ -410,6 +426,7 @@ function ensureFabric() {
410
426
  redoButton: 'redoButton',
411
427
  redoBtn: null,
412
428
  imageInput: 'imageInput',
429
+ uploadArea: null,
413
430
  enterCropModeButton: 'enterCropModeButton',
414
431
  cropBtn: null,
415
432
  applyCropButton: 'applyCropButton',
@@ -429,7 +446,8 @@ function ensureFabric() {
429
446
 
430
447
  // Auto-load initial image if provided
431
448
  if (this.options.initialImageBase64) {
432
- this.loadImage(this.options.initialImageBase64);
449
+ this.loadImage(this.options.initialImageBase64)
450
+ .catch(error => this._reportError('initialImageBase64 could not be loaded', error));
433
451
  } else {
434
452
  this._updatePlaceholderStatus();
435
453
  }
@@ -502,6 +520,66 @@ function ensureFabric() {
502
520
  );
503
521
  }
504
522
 
523
+ _normalizeFiniteNumber(value, fallback) {
524
+ const numericValue = Number(value);
525
+ return Number.isFinite(numericValue) ? numericValue : fallback;
526
+ }
527
+
528
+ _normalizePositiveNumber(value, fallback) {
529
+ const numericValue = this._normalizeFiniteNumber(value, fallback);
530
+ return numericValue > 0 ? numericValue : fallback;
531
+ }
532
+
533
+ _normalizeNonNegativeNumber(value, fallback) {
534
+ const numericValue = this._normalizeFiniteNumber(value, fallback);
535
+ return numericValue >= 0 ? numericValue : fallback;
536
+ }
537
+
538
+ _normalizePositiveInteger(value, fallback) {
539
+ const numericValue = this._normalizePositiveNumber(value, fallback);
540
+ return Math.max(1, Math.floor(numericValue));
541
+ }
542
+
543
+ _normalizeOptions() {
544
+ const options = this.options || {};
545
+ options.canvasWidth = this._normalizePositiveNumber(options.canvasWidth, 800);
546
+ options.canvasHeight = this._normalizePositiveNumber(options.canvasHeight, 600);
547
+ options.animationDuration = this._normalizeNonNegativeNumber(options.animationDuration, 300);
548
+
549
+ const minScale = this._normalizePositiveNumber(options.minScale, 0.1);
550
+ const maxScale = this._normalizePositiveNumber(options.maxScale, 5);
551
+ if (minScale > maxScale) {
552
+ options.minScale = 0.1;
553
+ options.maxScale = 5;
554
+ } else {
555
+ options.minScale = minScale;
556
+ options.maxScale = maxScale;
557
+ }
558
+ options.scaleStep = this._normalizePositiveNumber(options.scaleStep, 0.05);
559
+ options.rotationStep = this._normalizeFiniteNumber(options.rotationStep, 90);
560
+
561
+ options.downsampleMaxWidth = this._normalizePositiveNumber(options.downsampleMaxWidth, 4000);
562
+ options.downsampleMaxHeight = this._normalizePositiveNumber(options.downsampleMaxHeight, 3000);
563
+ options.downsampleQuality = options.downsampleQuality == null
564
+ ? 0.92
565
+ : Math.max(0, Math.min(1, this._normalizeFiniteNumber(options.downsampleQuality, 0.92)));
566
+ options.imageLoadTimeoutMs = this._normalizePositiveNumber(options.imageLoadTimeoutMs, 30000);
567
+
568
+ options.exportMultiplier = this._normalizePositiveNumber(options.exportMultiplier, 1);
569
+ options.maxExportPixels = this._normalizePositiveInteger(options.maxExportPixels, 50000000);
570
+ options.maxHistorySize = this._normalizePositiveInteger(options.maxHistorySize, 50);
571
+
572
+ options.defaultMaskWidth = this._normalizePositiveNumber(options.defaultMaskWidth, 50);
573
+ options.defaultMaskHeight = this._normalizePositiveNumber(options.defaultMaskHeight, 80);
574
+ options.maskLabelOffset = this._normalizeNonNegativeNumber(options.maskLabelOffset, 3);
575
+
576
+ if (options.crop) {
577
+ options.crop.minWidth = this._normalizePositiveNumber(options.crop.minWidth, 100);
578
+ options.crop.minHeight = this._normalizePositiveNumber(options.crop.minHeight, 100);
579
+ options.crop.padding = this._normalizeNonNegativeNumber(options.crop.padding, 10);
580
+ }
581
+ }
582
+
505
583
  _reportError(message, error = null) {
506
584
  const handler = this.options && this.options.onError;
507
585
  if (typeof handler !== 'function') return;
@@ -524,10 +602,19 @@ function ensureFabric() {
524
602
  }
525
603
  }
526
604
 
605
+ _emitSafeCallback(callback, message) {
606
+ if (typeof callback !== 'function') return;
607
+ try {
608
+ callback();
609
+ } catch (error) {
610
+ this._reportWarning(message, error);
611
+ }
612
+ }
613
+
527
614
  _notifyImageLoaded() {
528
615
  const optionsCallback = this.options && this.options.onImageLoaded;
529
616
  const callback = typeof optionsCallback === 'function' ? optionsCallback : this.onImageLoaded;
530
- if (typeof callback === 'function') callback();
617
+ this._emitSafeCallback(callback, 'onImageLoaded callback failed');
531
618
  }
532
619
 
533
620
  /**
@@ -660,13 +747,14 @@ function ensureFabric() {
660
747
  this._captureContainerOverflowState();
661
748
 
662
749
  const shouldPreserveScroll = options.preserveScroll === true;
663
- if (this.options.coverImageToCanvas) {
750
+ const layoutMode = this._getImageLayoutMode();
751
+ if (layoutMode === 'cover') {
664
752
  this.containerElement.style.overflow = 'scroll';
665
753
  if (!shouldPreserveScroll) {
666
754
  this.containerElement.scrollLeft = 0;
667
755
  this.containerElement.scrollTop = 0;
668
756
  }
669
- } else if (this.options.fitImageToCanvas) {
757
+ } else if (layoutMode === 'fit') {
670
758
  this.containerElement.style.overflow = 'auto';
671
759
  if (!shouldPreserveScroll) {
672
760
  this.containerElement.scrollLeft = 0;
@@ -792,7 +880,6 @@ function ensureFabric() {
792
880
  _loadImageFile(file) {
793
881
  if (!this._isSupportedImageFile(file)) {
794
882
  const error = new Error('Selected file is not a supported image');
795
- this._reportError('Selected file is not a supported image', error);
796
883
  return Promise.reject(error);
797
884
  }
798
885
 
@@ -838,6 +925,13 @@ function ensureFabric() {
838
925
  );
839
926
  }
840
927
 
928
+ _getImageLayoutMode() {
929
+ if (this.options.fitImageToCanvas) return 'fit';
930
+ if (this.options.coverImageToCanvas) return 'cover';
931
+ if (this.options.expandCanvasToImage) return 'expand';
932
+ return 'contain';
933
+ }
934
+
841
935
  /**
842
936
  * Loads a base64 data URL into the Fabric canvas as the base image.
843
937
  *
@@ -851,14 +945,22 @@ function ensureFabric() {
851
945
  if (!this._fabricLoaded) return;
852
946
  if (!this.canvas || this._disposed) return;
853
947
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
948
+ options = options || {};
854
949
  this._assertIdleForOperation('loadImage', options);
855
950
 
856
- this._isLoading = true;
857
- this._updateUI();
858
- this._warnOnImageLayoutOptionConflict();
859
- const transaction = this._captureLoadImageTransaction();
951
+ const isNestedOperation = this._isOwnInternalOperation(options);
952
+ const operationToken = isNestedOperation
953
+ ? this._getInternalOperationToken(options)
954
+ : this._beginBusyOperation('loadImage');
955
+ let transaction = null;
956
+ let shouldNotifyImageLoaded;
860
957
 
861
958
  try {
959
+ this._isLoading = true;
960
+ this._updateUI();
961
+ this._warnOnImageLayoutOptionConflict();
962
+ transaction = this._captureLoadImageTransaction();
963
+
862
964
  const imageElement = await this._createImageElement(imageBase64);
863
965
  if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
864
966
 
@@ -906,8 +1008,9 @@ function ensureFabric() {
906
1008
  const viewport = this._getContainerViewportSize();
907
1009
  const minWidth = viewport.width;
908
1010
  const minHeight = viewport.height;
1011
+ const layoutMode = this._getImageLayoutMode();
909
1012
 
910
- if (this.options.fitImageToCanvas) {
1013
+ if (layoutMode === 'fit') {
911
1014
  const canvasWidth = Math.max(1, minWidth - 1);
912
1015
  const canvasHeight = Math.max(1, minHeight - 1);
913
1016
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -915,13 +1018,13 @@ function ensureFabric() {
915
1018
  fabricImage.set({ left: 0, top: 0 });
916
1019
  fabricImage.scale(fitScale);
917
1020
  this.baseImageScale = fabricImage.scaleX || 1;
918
- } else if (this.options.coverImageToCanvas) {
1021
+ } else if (layoutMode === 'cover') {
919
1022
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
920
1023
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
921
1024
  fabricImage.set({ left: 0, top: 0 });
922
1025
  fabricImage.scale(layout.scale);
923
1026
  this.baseImageScale = fabricImage.scaleX || 1;
924
- } else if (this.options.expandCanvasToImage) {
1027
+ } else if (layoutMode === 'expand') {
925
1028
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
926
1029
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
927
1030
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -954,15 +1057,21 @@ function ensureFabric() {
954
1057
  this._updateUI();
955
1058
  this.canvas.renderAll();
956
1059
  this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
957
-
958
- this._notifyImageLoaded();
1060
+ shouldNotifyImageLoaded = true;
959
1061
  } catch (error) {
960
- await this._rollbackLoadImageTransaction(transaction);
1062
+ await this._rollbackLoadImageTransaction(
1063
+ transaction,
1064
+ this._withInternalOperationOptions(operationToken)
1065
+ );
961
1066
  throw error;
962
1067
  } finally {
963
1068
  this._isLoading = false;
1069
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
964
1070
  if (!this._disposed && this.canvas) this._updateUI();
965
1071
  }
1072
+ if (shouldNotifyImageLoaded && !this._disposed && this.canvas) {
1073
+ this._notifyImageLoaded();
1074
+ }
966
1075
  }
967
1076
 
968
1077
  /**
@@ -990,6 +1099,7 @@ function ensureFabric() {
990
1099
  return !!(
991
1100
  this.isAnimating ||
992
1101
  this._cropMode ||
1102
+ this._isApplyingCrop ||
993
1103
  this._isLoading ||
994
1104
  this._activeOperationToken ||
995
1105
  (this.animationQueue && this.animationQueue.isBusy())
@@ -1092,13 +1202,13 @@ function ensureFabric() {
1092
1202
  };
1093
1203
  }
1094
1204
 
1095
- async _rollbackLoadImageTransaction(transaction) {
1205
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
1096
1206
  if (!transaction || !this.canvas || this._disposed) return;
1097
1207
  let didRestoreCanvasState = false;
1098
1208
  let didFailCanvasRestore = false;
1099
1209
  try {
1100
1210
  if (transaction.canvasState) {
1101
- await this.loadFromState(transaction.canvasState);
1211
+ await this.loadFromState(transaction.canvasState, options);
1102
1212
  didRestoreCanvasState = true;
1103
1213
  }
1104
1214
  } catch (error) {
@@ -1413,10 +1523,18 @@ function ensureFabric() {
1413
1523
 
1414
1524
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1415
1525
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1526
+ const safetyMargin = this._getScrollSafetyMargin();
1527
+ const layoutMode = this._getImageLayoutMode();
1528
+ const shouldReserveNoScrollbarMargin = layoutMode === 'fit' || layoutMode === 'cover';
1529
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1530
+ const margin = hasOppositeScrollbar ? safetyMargin : (shouldReserveNoScrollbarMargin ? 1 : 0);
1531
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1532
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1533
+ };
1416
1534
 
1417
1535
  return {
1418
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1419
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1536
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1537
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1420
1538
  viewportWidth: effectiveWidth,
1421
1539
  viewportHeight: effectiveHeight,
1422
1540
  hasHorizontal,
@@ -1549,6 +1667,50 @@ function ensureFabric() {
1549
1667
  }
1550
1668
  }
1551
1669
 
1670
+ _getSerializableStateObjects() {
1671
+ if (!this.canvas) return [];
1672
+ return this.canvas.getObjects().filter(object => !object.isCropRect && !object.maskLabel);
1673
+ }
1674
+
1675
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1676
+ if (!Array.isArray(serializedObjects)) return;
1677
+ const fabricObjects = this._getSerializableStateObjects();
1678
+ const numericProperties = [
1679
+ 'left',
1680
+ 'top',
1681
+ 'width',
1682
+ 'height',
1683
+ 'scaleX',
1684
+ 'scaleY',
1685
+ 'angle',
1686
+ 'skewX',
1687
+ 'skewY',
1688
+ 'cropX',
1689
+ 'cropY',
1690
+ 'radius',
1691
+ 'rx',
1692
+ 'ry',
1693
+ 'strokeWidth'
1694
+ ];
1695
+
1696
+ serializedObjects.forEach((serializedObject, index) => {
1697
+ const fabricObject = fabricObjects[index];
1698
+ if (!serializedObject || !fabricObject) return;
1699
+
1700
+ numericProperties.forEach(property => {
1701
+ const numericValue = Number(fabricObject[property]);
1702
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1703
+ });
1704
+
1705
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1706
+ serializedObject.points = fabricObject.points.map(point => ({
1707
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1708
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1709
+ }));
1710
+ }
1711
+ });
1712
+ }
1713
+
1552
1714
  _restoreMaskControls(mask) {
1553
1715
  if (!mask) return;
1554
1716
 
@@ -1598,6 +1760,7 @@ function ensureFabric() {
1598
1760
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1599
1761
  if (Array.isArray(jsonObject.objects)) {
1600
1762
  jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
1763
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1601
1764
  }
1602
1765
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1603
1766
  return JSON.stringify(jsonObject);
@@ -1680,6 +1843,13 @@ function ensureFabric() {
1680
1843
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1681
1844
  }
1682
1845
 
1846
+ _hasScaledImageEdge(axis) {
1847
+ if (!this.originalImage) return false;
1848
+ const scale = Number(axis === 'y' ? this.originalImage.scaleY : this.originalImage.scaleX);
1849
+ if (!Number.isFinite(scale)) return false;
1850
+ return Math.abs(scale - 1) > 0.01;
1851
+ }
1852
+
1683
1853
  _getPartialExportEdges(bounds) {
1684
1854
  if (!bounds) return null;
1685
1855
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1689,8 +1859,8 @@ function ensureFabric() {
1689
1859
  return {
1690
1860
  left: this._hasFractionalCanvasEdge(bounds.left),
1691
1861
  top: this._hasFractionalCanvasEdge(bounds.top),
1692
- right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1693
- bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1862
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge('x'),
1863
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge('y')
1694
1864
  };
1695
1865
  }
1696
1866
 
@@ -1756,7 +1926,8 @@ function ensureFabric() {
1756
1926
  * @private
1757
1927
  */
1758
1928
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
1759
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1929
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1930
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1760
1931
  const safeFormat = this._normalizeImageFormat(format);
1761
1932
  const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1762
1933
  let regionDataUrl = this.canvas.toDataURL({
@@ -1774,6 +1945,30 @@ function ensureFabric() {
1774
1945
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1775
1946
  }
1776
1947
 
1948
+ _getSafeExportMultiplier(multiplier) {
1949
+ const numericMultiplier = Number(multiplier);
1950
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1951
+ throw new Error('Export multiplier must be a finite positive number');
1952
+ }
1953
+ return Math.max(1, numericMultiplier);
1954
+ }
1955
+
1956
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1957
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1958
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1959
+ const outputWidth = Math.ceil(width * safeMultiplier);
1960
+ const outputHeight = Math.ceil(height * safeMultiplier);
1961
+ const outputPixels = outputWidth * outputHeight;
1962
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1963
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0
1964
+ ? Math.floor(configuredMaxPixels)
1965
+ : 50000000;
1966
+
1967
+ if (outputPixels > maxPixels) {
1968
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1969
+ }
1970
+ }
1971
+
1777
1972
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1778
1973
  const imageElement = await this._createImageElement(dataUrl);
1779
1974
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1792,7 +1987,24 @@ function ensureFabric() {
1792
1987
  _getJpegBackgroundColor() {
1793
1988
  const backgroundColor = String(this.options.backgroundColor || '').trim();
1794
1989
  if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return '#ffffff';
1795
- return backgroundColor;
1990
+ return this._isValidCanvasFillStyle(backgroundColor) ? backgroundColor : '#ffffff';
1991
+ }
1992
+
1993
+ _isValidCanvasFillStyle(color) {
1994
+ try {
1995
+ if (typeof document === 'undefined' || !document.createElement) return false;
1996
+ const validationCanvas = document.createElement('canvas');
1997
+ const context = validationCanvas.getContext && validationCanvas.getContext('2d');
1998
+ if (!context) return false;
1999
+ context.fillStyle = '#010203';
2000
+ context.fillStyle = color;
2001
+ if (context.fillStyle !== '#010203') return true;
2002
+ context.fillStyle = '#040506';
2003
+ context.fillStyle = color;
2004
+ return context.fillStyle !== '#040506';
2005
+ } catch {
2006
+ return false;
2007
+ }
1796
2008
  }
1797
2009
 
1798
2010
  _isTransparentCssColor(color) {
@@ -1826,6 +2038,7 @@ function ensureFabric() {
1826
2038
 
1827
2039
  _decodeBase64Payload(base64Payload) {
1828
2040
  const payload = String(base64Payload || '');
2041
+ if (!payload) throw new Error('Data URL base64 payload is empty');
1829
2042
  if (typeof atob === 'function') {
1830
2043
  return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
1831
2044
  }
@@ -1835,6 +2048,14 @@ function ensureFabric() {
1835
2048
  throw new Error('Base64 decoding is unavailable');
1836
2049
  }
1837
2050
 
2051
+ _decodeDataUrlPayload(dataUrl) {
2052
+ const match = String(dataUrl || '').match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
2053
+ if (!match || !match[2]) {
2054
+ throw new Error('Export produced an invalid or empty base64 data URL');
2055
+ }
2056
+ return this._decodeBase64Payload(match[2]);
2057
+ }
2058
+
1838
2059
  /**
1839
2060
  * Gets the top-left corner coordinates of the given object.
1840
2061
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1953,13 +2174,49 @@ function ensureFabric() {
1953
2174
  const currentHeight = this.canvas.getHeight();
1954
2175
  let requiredWidth = currentWidth;
1955
2176
  let requiredHeight = currentHeight;
1956
- fabricObjects.forEach(fabricObject => {
2177
+ const layoutMode = this._getImageLayoutMode();
2178
+ const usesScrollableFitBounds = layoutMode === 'fit' || layoutMode === 'cover';
2179
+ let contentWidth = 0;
2180
+ let contentHeight = 0;
2181
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1957
2182
  if (!fabricObject) return;
1958
2183
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1959
2184
  const boundingRect = fabricObject.getBoundingRect(true, true);
1960
- requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1961
- requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
2185
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
2186
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
2187
+ contentWidth = Math.max(contentWidth, right);
2188
+ contentHeight = Math.max(contentHeight, bottom);
2189
+ return { right, bottom };
2190
+ };
2191
+ fabricObjects.forEach(fabricObject => {
2192
+ const bounds = includeObjectBounds(fabricObject, padding);
2193
+ if (!bounds) return;
2194
+ requiredWidth = Math.max(requiredWidth, bounds.right);
2195
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1962
2196
  });
2197
+ if (usesScrollableFitBounds) {
2198
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
2199
+ this.canvas.getObjects().forEach(object => {
2200
+ if (object && object.maskId) includeObjectBounds(object, padding);
2201
+ });
2202
+
2203
+ const contentSize = this._getScrollableCanvasSize(
2204
+ Math.max(1, contentWidth),
2205
+ Math.max(1, contentHeight)
2206
+ );
2207
+
2208
+ const newWidth = contentSize.hasHorizontal
2209
+ ? Math.max(currentWidth, contentSize.width)
2210
+ : contentSize.width;
2211
+ const newHeight = contentSize.hasVertical
2212
+ ? Math.max(currentHeight, contentSize.height)
2213
+ : contentSize.height;
2214
+
2215
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
2216
+ this._setCanvasSizeInt(newWidth, newHeight);
2217
+ }
2218
+ return;
2219
+ }
1963
2220
  let minWidth = 0;
1964
2221
  let minHeight = 0;
1965
2222
  if (this.containerElement) {
@@ -1979,16 +2236,65 @@ function ensureFabric() {
1979
2236
  }
1980
2237
  }
1981
2238
 
1982
- /**
1983
- * Expands the canvas so one object remains visible after an edit.
1984
- *
1985
- * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1986
- * @param {number} [padding=10] - Extra canvas space after the object edge.
1987
- * @returns {void}
1988
- * @private
1989
- */
1990
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1991
- this._expandCanvasToFitObjects([fabricObject], padding);
2239
+ _captureImageDisplayBounds() {
2240
+ if (!this.originalImage || !this.canvas) return null;
2241
+ this.originalImage.setCoords();
2242
+ const bounds = this.originalImage.getBoundingRect(true, true);
2243
+ const width = Number(bounds && bounds.width);
2244
+ const height = Number(bounds && bounds.height);
2245
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
2246
+
2247
+ return {
2248
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
2249
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
2250
+ width,
2251
+ height
2252
+ };
2253
+ }
2254
+
2255
+ _restoreImageDisplayBounds(displayBounds) {
2256
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
2257
+ const imageWidth = Number(this.originalImage.width);
2258
+ const imageHeight = Number(this.originalImage.height);
2259
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
2260
+
2261
+ const scaleX = Number(displayBounds.width) / imageWidth;
2262
+ const scaleY = Number(displayBounds.height) / imageHeight;
2263
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
2264
+
2265
+ const left = Number(displayBounds.left) || 0;
2266
+ const top = Number(displayBounds.top) || 0;
2267
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
2268
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
2269
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
2270
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
2271
+ const layoutMode = this._getImageLayoutMode();
2272
+ if (layoutMode === 'fit' || layoutMode === 'cover') {
2273
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
2274
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
2275
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
2276
+ }
2277
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
2278
+ this._setCanvasSizeInt(
2279
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
2280
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
2281
+ );
2282
+ }
2283
+
2284
+ this.originalImage.set({
2285
+ originX: 'left',
2286
+ originY: 'top',
2287
+ left,
2288
+ top,
2289
+ scaleX,
2290
+ scaleY
2291
+ });
2292
+ this.originalImage.setCoords();
2293
+ this.baseImageScale = scaleX;
2294
+ this.currentScale = 1;
2295
+ this.currentRotation = Number(this.originalImage.angle) || 0;
2296
+ this._updateInputs();
2297
+ this.canvas.renderAll();
1992
2298
  }
1993
2299
 
1994
2300
  /**
@@ -2004,7 +2310,14 @@ function ensureFabric() {
2004
2310
  } catch (error) {
2005
2311
  return Promise.reject(error);
2006
2312
  }
2007
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
2313
+ return this.animationQueue.add(async () => {
2314
+ const operationToken = this._beginBusyOperation('scaleImage');
2315
+ try {
2316
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
2317
+ } finally {
2318
+ this._endBusyOperation(operationToken);
2319
+ }
2320
+ })
2008
2321
  .finally(() => {
2009
2322
  if (!this._disposed && this.canvas) this._updateUI();
2010
2323
  });
@@ -2056,7 +2369,7 @@ function ensureFabric() {
2056
2369
  if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2057
2370
  throw new Error(`${operationName} cannot run while crop mode is active`);
2058
2371
  }
2059
- if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
2372
+ if ((this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) && !isOwnInternalOperation) {
2060
2373
  throw new Error(`${operationName} cannot run while an animation is running`);
2061
2374
  }
2062
2375
  if (this._isLoading && !isOwnInternalOperation) {
@@ -2147,10 +2460,12 @@ function ensureFabric() {
2147
2460
  async _scaleImageImpl(factor, options = {}) {
2148
2461
  if (!this.originalImage || this._disposed) return;
2149
2462
  if (this.isAnimating) return;
2463
+ const numericFactor = Number(factor);
2464
+ if (!Number.isFinite(numericFactor)) return;
2150
2465
  const saveHistory = options.saveHistory !== false;
2151
2466
  let didStartAnimation = false;
2152
2467
  try {
2153
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
2468
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, numericFactor));
2154
2469
  this.currentScale = factor;
2155
2470
  this.isAnimating = true;
2156
2471
  didStartAnimation = true;
@@ -2179,7 +2494,7 @@ function ensureFabric() {
2179
2494
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2180
2495
 
2181
2496
  this._updateInputs();
2182
- if (saveHistory) this.saveState();
2497
+ if (saveHistory) this.saveState(options);
2183
2498
  } finally {
2184
2499
  if (didStartAnimation) {
2185
2500
  this.isAnimating = false;
@@ -2202,7 +2517,14 @@ function ensureFabric() {
2202
2517
  } catch (error) {
2203
2518
  return Promise.reject(error);
2204
2519
  }
2205
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
2520
+ return this.animationQueue.add(async () => {
2521
+ const operationToken = this._beginBusyOperation('rotateImage');
2522
+ try {
2523
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2524
+ } finally {
2525
+ this._endBusyOperation(operationToken);
2526
+ }
2527
+ })
2206
2528
  .finally(() => {
2207
2529
  if (!this._disposed && this.canvas) this._updateUI();
2208
2530
  });
@@ -2218,7 +2540,8 @@ function ensureFabric() {
2218
2540
  async _rotateImageImpl(degrees, options = {}) {
2219
2541
  if (!this.originalImage || this._disposed) return;
2220
2542
  if (this.isAnimating) return;
2221
- if (isNaN(degrees)) return;
2543
+ const numericDegrees = Number(degrees);
2544
+ if (!Number.isFinite(numericDegrees)) return;
2222
2545
  const saveHistory = options.saveHistory !== false;
2223
2546
  const image = this.originalImage;
2224
2547
  const previousOriginX = image.originX || 'left';
@@ -2227,6 +2550,7 @@ function ensureFabric() {
2227
2550
  let didStartAnimation = false;
2228
2551
  let didCompleteRotation = false;
2229
2552
  try {
2553
+ degrees = numericDegrees;
2230
2554
  this.currentRotation = degrees;
2231
2555
  this.isAnimating = true;
2232
2556
  didStartAnimation = true;
@@ -2253,7 +2577,7 @@ function ensureFabric() {
2253
2577
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2254
2578
 
2255
2579
  this._updateInputs();
2256
- if (saveHistory) this.saveState();
2580
+ if (saveHistory) this.saveState(options);
2257
2581
  didCompleteRotation = true;
2258
2582
  } finally {
2259
2583
  if (!didCompleteRotation && !this._disposed && image) {
@@ -2282,19 +2606,22 @@ function ensureFabric() {
2282
2606
  }
2283
2607
 
2284
2608
  return this.animationQueue.add(async () => {
2609
+ const operationToken = this._beginBusyOperation('resetImageTransform');
2285
2610
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
2286
2611
  try {
2287
- await this._scaleImageImpl(1, { saveHistory: false });
2288
- await this._rotateImageImpl(0, { saveHistory: false });
2612
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2613
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2289
2614
  const after = this._captureCanvasStateOrThrow('resetImageTransform');
2290
2615
  this._pushStateTransition(before, after);
2291
2616
  } catch (error) {
2292
2617
  try {
2293
- await this.loadFromState(before);
2618
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
2294
2619
  } catch (restoreError) {
2295
2620
  this._reportError('resetImageTransform rollback failed', restoreError);
2296
2621
  }
2297
2622
  throw error;
2623
+ } finally {
2624
+ this._endBusyOperation(operationToken);
2298
2625
  }
2299
2626
  }).finally(() => {
2300
2627
  if (!this._disposed && this.canvas) this._updateUI();
@@ -2321,8 +2648,13 @@ function ensureFabric() {
2321
2648
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
2322
2649
  * @public
2323
2650
  */
2324
- loadFromState(serializedState) {
2651
+ loadFromState(serializedState, options = {}) {
2325
2652
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2653
+ try {
2654
+ this._assertIdleForOperation('loadFromState', options);
2655
+ } catch (error) {
2656
+ return Promise.reject(error);
2657
+ }
2326
2658
  if (this._cropMode || this._cropRect) {
2327
2659
  this._removeCropRect();
2328
2660
  this._restoreCropObjectState();
@@ -2508,9 +2840,17 @@ function ensureFabric() {
2508
2840
  * @returns {void}
2509
2841
  * @public
2510
2842
  */
2511
- saveState() {
2843
+ saveState(options = {}) {
2512
2844
  if (!this.canvas) return;
2513
2845
 
2846
+ try {
2847
+ this._assertIdleForOperation('saveState', options);
2848
+ } catch (error) {
2849
+ this._reportError('saveState blocked', error);
2850
+ this._updateUI();
2851
+ return;
2852
+ }
2853
+
2514
2854
  try {
2515
2855
  const after = this._captureCanvasStateOrThrow('saveState');
2516
2856
  const before = this._lastSnapshot || after;
@@ -2518,14 +2858,14 @@ function ensureFabric() {
2518
2858
  let executedOnce = false;
2519
2859
 
2520
2860
  const command = new Command(
2521
- () => {
2861
+ (commandOptions = {}) => {
2522
2862
  if (executedOnce) {
2523
- return this.loadFromState(after);
2863
+ return this.loadFromState(after, commandOptions);
2524
2864
  }
2525
2865
  executedOnce = true;
2526
2866
  return undefined;
2527
2867
  },
2528
- () => this.loadFromState(before)
2868
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2529
2869
  );
2530
2870
 
2531
2871
  this.historyManager.execute(command);
@@ -2557,8 +2897,8 @@ function ensureFabric() {
2557
2897
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2558
2898
 
2559
2899
  const command = new Command(
2560
- () => this.loadFromState(after),
2561
- () => this.loadFromState(before)
2900
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2901
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2562
2902
  );
2563
2903
  this.historyManager.push(command);
2564
2904
  this._lastSnapshot = after;
@@ -2572,8 +2912,17 @@ function ensureFabric() {
2572
2912
  * @public
2573
2913
  */
2574
2914
  undo() {
2575
- return this.historyManager.undo()
2915
+ try {
2916
+ this._assertIdleForOperation('undo');
2917
+ } catch (error) {
2918
+ return Promise.reject(error);
2919
+ }
2920
+ const operationToken = this._beginBusyOperation('undo');
2921
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken))
2576
2922
  .then(() => { this._updateUI(); })
2923
+ .finally(() => {
2924
+ this._endBusyOperation(operationToken);
2925
+ })
2577
2926
  .catch(error => {
2578
2927
  this._reportError('undo failed', error);
2579
2928
  throw error;
@@ -2587,8 +2936,17 @@ function ensureFabric() {
2587
2936
  * @public
2588
2937
  */
2589
2938
  redo() {
2590
- return this.historyManager.redo()
2939
+ try {
2940
+ this._assertIdleForOperation('redo');
2941
+ } catch (error) {
2942
+ return Promise.reject(error);
2943
+ }
2944
+ const operationToken = this._beginBusyOperation('redo');
2945
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken))
2591
2946
  .then(() => { this._updateUI(); })
2947
+ .finally(() => {
2948
+ this._endBusyOperation(operationToken);
2949
+ })
2592
2950
  .catch(error => {
2593
2951
  this._reportError('redo failed', error);
2594
2952
  throw error;
@@ -2712,33 +3070,70 @@ function ensureFabric() {
2712
3070
  return value != null ? value : fallback;
2713
3071
  };
2714
3072
 
2715
- if (maskConfig.left === undefined && this._lastMask) {
2716
- const previousMask = this._lastMask;
2717
- if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
2718
- const previousBounds = typeof previousMask.getBoundingRect === 'function'
2719
- ? previousMask.getBoundingRect(true, true)
2720
- : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2721
- left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2722
- top = Math.round(previousBounds.top ?? firstOffset);
2723
- } else {
2724
- left = resolveValue(maskConfig.left, firstOffset, 'width');
2725
- top = resolveValue(maskConfig.top, firstOffset, 'height');
3073
+ const rejectInvalidMask = (message, error = null) => {
3074
+ this._reportWarning(`createMask: ${message}`, error);
3075
+ return null;
3076
+ };
3077
+
3078
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
3079
+ const resolvedValue = resolveValue(value, fallback, axis);
3080
+ const numericValue = Number(resolvedValue);
3081
+ if (!Number.isFinite(numericValue)) {
3082
+ throw new Error(`${fieldName} must be a finite number`);
3083
+ }
3084
+ if (constraints.positive && numericValue <= 0) {
3085
+ throw new Error(`${fieldName} must be greater than 0`);
3086
+ }
3087
+ if (constraints.nonNegative && numericValue < 0) {
3088
+ throw new Error(`${fieldName} must be 0 or greater`);
3089
+ }
3090
+ return numericValue;
3091
+ };
3092
+
3093
+ try {
3094
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, 'width', 'gap', { nonNegative: true });
3095
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, 'width', 'width', { positive: true });
3096
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, 'height', 'height', { positive: true });
3097
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, 'width', 'angle');
3098
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, 'width', 'alpha')));
3099
+
3100
+ if (maskConfig.left === undefined && this._lastMask) {
3101
+ const previousMask = this._lastMask;
3102
+ if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
3103
+ const previousBounds = typeof previousMask.getBoundingRect === 'function'
3104
+ ? previousMask.getBoundingRect(true, true)
3105
+ : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
3106
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
3107
+ top = Math.round(previousBounds.top ?? firstOffset);
3108
+ } else {
3109
+ left = resolveNumber(maskConfig.left, firstOffset, 'width', 'left');
3110
+ top = resolveNumber(maskConfig.top, firstOffset, 'height', 'top');
3111
+ }
3112
+ } catch (error) {
3113
+ return rejectInvalidMask('invalid numeric configuration', error);
2726
3114
  }
2727
3115
 
2728
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
2729
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
2730
3116
  maskConfig.left = left;
2731
3117
  maskConfig.top = top;
2732
3118
 
2733
3119
  let mask;
2734
3120
  if (typeof maskConfig.fabricGenerator === 'function') {
2735
- mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
3121
+ try {
3122
+ mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
3123
+ } catch (error) {
3124
+ return rejectInvalidMask('fabricGenerator failed', error);
3125
+ }
2736
3126
  } else {
2737
3127
  switch (shapeType) {
2738
3128
  case 'circle':
3129
+ try {
3130
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min', 'radius', { positive: true });
3131
+ } catch (error) {
3132
+ return rejectInvalidMask('invalid circle radius', error);
3133
+ }
2739
3134
  mask = new fabric.Circle({
2740
3135
  left, top,
2741
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
3136
+ radius: maskConfig.radius,
2742
3137
  fill: maskConfig.color,
2743
3138
  opacity: maskConfig.alpha,
2744
3139
  angle: maskConfig.angle,
@@ -2746,10 +3141,16 @@ function ensureFabric() {
2746
3141
  });
2747
3142
  break;
2748
3143
  case 'ellipse':
3144
+ try {
3145
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, 'width', 'rx', { positive: true });
3146
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, 'height', 'ry', { positive: true });
3147
+ } catch (error) {
3148
+ return rejectInvalidMask('invalid ellipse radius', error);
3149
+ }
2749
3150
  mask = new fabric.Ellipse({
2750
3151
  left, top,
2751
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
2752
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
3152
+ rx: maskConfig.rx,
3153
+ ry: maskConfig.ry,
2753
3154
  fill: maskConfig.color,
2754
3155
  opacity: maskConfig.alpha,
2755
3156
  angle: maskConfig.angle,
@@ -2758,11 +3159,31 @@ function ensureFabric() {
2758
3159
  break;
2759
3160
  case 'polygon': {
2760
3161
  let polygonPoints = maskConfig.points || [];
2761
- if (Array.isArray(polygonPoints) && polygonPoints.length) {
2762
- // Ensure numeric {x,y} objects for fabric.Polygon.
2763
- polygonPoints = polygonPoints.map(point => Array.isArray(point)
2764
- ? { x: Number(point[0]), y: Number(point[1]) }
2765
- : { x: Number(point.x), y: Number(point.y) });
3162
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
3163
+ return rejectInvalidMask('polygon masks require at least three points');
3164
+ }
3165
+ try {
3166
+ polygonPoints = polygonPoints.map(point => {
3167
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
3168
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
3169
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3170
+ throw new Error('polygon point coordinates must be finite numbers');
3171
+ }
3172
+ return { x, y };
3173
+ });
3174
+ } catch (error) {
3175
+ return rejectInvalidMask('invalid polygon points', error);
3176
+ }
3177
+ const uniquePointKeys = new Set(polygonPoints.map(point => `${point.x}:${point.y}`));
3178
+ if (uniquePointKeys.size !== polygonPoints.length) {
3179
+ return rejectInvalidMask('polygon points must not contain duplicates');
3180
+ }
3181
+ const doubleArea = polygonPoints.reduce((area, point, index) => {
3182
+ const nextPoint = polygonPoints[(index + 1) % polygonPoints.length];
3183
+ return area + point.x * nextPoint.y - nextPoint.x * point.y;
3184
+ }, 0);
3185
+ if (Math.abs(doubleArea) < 0.000001) {
3186
+ return rejectInvalidMask('polygon masks must have a non-zero area');
2766
3187
  }
2767
3188
  mask = new fabric.Polygon(polygonPoints, {
2768
3189
  left, top,
@@ -2775,10 +3196,16 @@ function ensureFabric() {
2775
3196
  }
2776
3197
  case 'rect':
2777
3198
  default:
3199
+ try {
3200
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, 'width', 'rx', { nonNegative: true });
3201
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, 'height', 'ry', { nonNegative: true });
3202
+ } catch (error) {
3203
+ return rejectInvalidMask('invalid rectangle corner radius', error);
3204
+ }
2778
3205
  mask = new fabric.Rect({
2779
3206
  left, top,
2780
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
2781
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
3207
+ width: maskConfig.width,
3208
+ height: maskConfig.height,
2782
3209
  fill: maskConfig.color,
2783
3210
  opacity: maskConfig.alpha,
2784
3211
  angle: maskConfig.angle,
@@ -2819,12 +3246,12 @@ function ensureFabric() {
2819
3246
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2820
3247
  });
2821
3248
  this._rebindMaskEvents(mask);
2822
- this._expandCanvasToFitObject(mask);
3249
+ this._expandCanvasToFitObjects([mask]);
2823
3250
 
2824
3251
  // Store placement values so the next mask can be positioned beside this one.
2825
3252
  this._lastMaskInitialLeft = left;
2826
3253
  this._lastMaskInitialTop = top;
2827
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
3254
+ this._lastMaskInitialWidth = maskConfig.width;
2828
3255
 
2829
3256
  const maskId = ++this.maskCounter;
2830
3257
  mask.set({
@@ -2842,7 +3269,12 @@ function ensureFabric() {
2842
3269
  this.canvas.renderAll();
2843
3270
  this.saveState();
2844
3271
 
2845
- if (typeof maskConfig.onCreate === 'function') maskConfig.onCreate(mask, this.canvas);
3272
+ if (typeof maskConfig.onCreate === 'function') {
3273
+ this._emitSafeCallback(
3274
+ () => maskConfig.onCreate(mask, this.canvas),
3275
+ 'createMask onCreate callback failed'
3276
+ );
3277
+ }
2846
3278
  return mask;
2847
3279
  }
2848
3280
 
@@ -3057,8 +3489,15 @@ function ensureFabric() {
3057
3489
  this._removeLabelForMask(mask);
3058
3490
  let textObject = null;
3059
3491
  if (this.options.label && typeof this.options.label.create === 'function') {
3060
- textObject = this.options.label.create(mask, fabric);
3061
- if (!textObject || typeof textObject.set !== 'function') {
3492
+ let didLabelCreateThrow = false;
3493
+ try {
3494
+ textObject = this.options.label.create(mask, fabric);
3495
+ } catch (error) {
3496
+ didLabelCreateThrow = true;
3497
+ this._reportWarning('label.create() failed; using the default label', error);
3498
+ textObject = null;
3499
+ }
3500
+ if (!didLabelCreateThrow && (!textObject || typeof textObject.set !== 'function')) {
3062
3501
  this._reportWarning('label.create() returned an invalid Fabric object; using the default label');
3063
3502
  textObject = null;
3064
3503
  }
@@ -3079,7 +3518,12 @@ function ensureFabric() {
3079
3518
  };
3080
3519
  if (this.options.label) {
3081
3520
  if (typeof this.options.label.getText === 'function') {
3082
- labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
3521
+ try {
3522
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
3523
+ } catch (error) {
3524
+ this._reportWarning('label.getText() failed; using the mask name', error);
3525
+ labelText = mask.maskName;
3526
+ }
3083
3527
  }
3084
3528
  // Merge external styles
3085
3529
  if (this.options.label.textOptions) {
@@ -3274,6 +3718,7 @@ function ensureFabric() {
3274
3718
  this._assertIdleForOperation('mergeMasks');
3275
3719
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3276
3720
  if (!masks.length) return;
3721
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
3277
3722
  const beforeJson = this._serializeCanvasState();
3278
3723
  const operationToken = this._beginBusyOperation('mergeMasks');
3279
3724
 
@@ -3294,12 +3739,13 @@ function ensureFabric() {
3294
3739
  preserveScroll: true,
3295
3740
  resetMaskCounter: false
3296
3741
  }));
3742
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
3297
3743
  const afterJson = this._serializeCanvasState();
3298
3744
  this._pushStateTransition(beforeJson, afterJson);
3299
3745
  } catch (error) {
3300
3746
  this._reportError('merge error', error);
3301
3747
  try {
3302
- await this.loadFromState(beforeJson);
3748
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
3303
3749
  } catch (restoreError) {
3304
3750
  this._reportError('mergeMasks rollback failed', restoreError);
3305
3751
  }
@@ -3361,13 +3807,19 @@ function ensureFabric() {
3361
3807
  */
3362
3808
  async exportImageBase64(options = {}) {
3363
3809
  if (!this.originalImage) throw new Error('No image loaded');
3810
+ options = options || {};
3364
3811
  this._assertIdleForOperation('exportImageBase64', options);
3812
+ const isNestedOperation = this._isOwnInternalOperation(options);
3813
+ const operationToken = isNestedOperation
3814
+ ? this._getInternalOperationToken(options)
3815
+ : this._beginBusyOperation('exportImageBase64');
3365
3816
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
3366
3817
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
3367
3818
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
3368
3819
  const format = this._normalizeImageFormat(options.fileType || options.format);
3369
3820
 
3370
- if (!exportImageArea) {
3821
+ try {
3822
+ if (!exportImageArea) {
3371
3823
  const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
3372
3824
  const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
3373
3825
  const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
@@ -3399,15 +3851,15 @@ function ensureFabric() {
3399
3851
  this._restoreActiveObjectBackup(activeObjectBackup);
3400
3852
  this.canvas.renderAll();
3401
3853
  }
3402
- }
3854
+ }
3403
3855
 
3404
- // Render masks as export shapes without mutating their editable styles.
3405
- const masks = this.canvas.getObjects().filter(object => object.maskId);
3406
- const maskStyleBackups = this._captureMaskExportBackups(masks);
3407
- const labelBackups = this._captureMaskLabelBackups(masks);
3408
- const activeObjectBackup = this._captureActiveObjectBackup();
3856
+ // Render masks as export shapes without mutating their editable styles.
3857
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
3858
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3859
+ const labelBackups = this._captureMaskLabelBackups(masks);
3860
+ const activeObjectBackup = this._captureActiveObjectBackup();
3409
3861
 
3410
- try {
3862
+ try {
3411
3863
  // Labels are UI overlays and should not be part of the flattened export.
3412
3864
  masks.forEach(mask => this._removeLabelForMask(mask));
3413
3865
  this.canvas.discardActiveObject();
@@ -3438,6 +3890,9 @@ function ensureFabric() {
3438
3890
  this._restoreActiveObjectBackup(activeObjectBackup);
3439
3891
  this.canvas.renderAll();
3440
3892
  }
3893
+ } finally {
3894
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3895
+ }
3441
3896
  }
3442
3897
 
3443
3898
  /**
@@ -3471,7 +3926,12 @@ function ensureFabric() {
3471
3926
  */
3472
3927
  async exportImageFile(options = {}) {
3473
3928
  if (!this.originalImage) throw new Error('No image loaded');
3474
- this._assertIdleForOperation('exportImageFile');
3929
+ options = options || {};
3930
+ this._assertIdleForOperation('exportImageFile', options);
3931
+ const isNestedOperation = this._isOwnInternalOperation(options);
3932
+ const operationToken = isNestedOperation
3933
+ ? this._getInternalOperationToken(options)
3934
+ : this._beginBusyOperation('exportImageFile');
3475
3935
  const {
3476
3936
  mergeMask = true,
3477
3937
  fileType = 'jpeg',
@@ -3483,52 +3943,56 @@ function ensureFabric() {
3483
3943
  const safeFileType = this._normalizeImageFormat(fileType);
3484
3944
  const normalizedQuality = this._normalizeQuality(quality);
3485
3945
 
3486
- // Generate the data URL in the requested export mode.
3487
- let imageBase64;
3488
- if (mergeMask) {
3489
- imageBase64 = await this.exportImageBase64({
3490
- exportImageArea: true,
3491
- multiplier,
3492
- quality: normalizedQuality,
3493
- fileType: safeFileType
3494
- });
3495
- } else {
3496
- imageBase64 = await this.exportImageBase64({
3497
- exportImageArea: false,
3498
- multiplier,
3499
- quality: normalizedQuality,
3500
- fileType: safeFileType
3501
- });
3502
- }
3946
+ try {
3947
+ // Generate the data URL in the requested export mode.
3948
+ let imageBase64;
3949
+ if (mergeMask) {
3950
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3951
+ exportImageArea: true,
3952
+ multiplier,
3953
+ quality: normalizedQuality,
3954
+ fileType: safeFileType
3955
+ }));
3956
+ } else {
3957
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3958
+ exportImageArea: false,
3959
+ multiplier,
3960
+ quality: normalizedQuality,
3961
+ fileType: safeFileType
3962
+ }));
3963
+ }
3503
3964
 
3504
- // Convert to the required image format
3505
- let imageDataUrl = imageBase64;
3506
- if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3507
- // Redraw the exported data URL when the browser returned a different image format.
3508
- imageDataUrl = await new Promise((resolve, reject) => {
3509
- const imageElement = new window.Image();
3510
- imageElement.crossOrigin = "Anonymous";
3511
- imageElement.onload = () => {
3512
- try {
3513
- const offscreenCanvas = document.createElement('canvas');
3514
- offscreenCanvas.width = imageElement.width;
3515
- offscreenCanvas.height = imageElement.height;
3516
- const context = offscreenCanvas.getContext('2d');
3517
- if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
3518
- context.drawImage(imageElement, 0, 0);
3519
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3520
- resolve(convertedDataUrl);
3521
- } catch (error) { reject(error); }
3522
- };
3523
- imageElement.onerror = reject;
3524
- imageElement.src = imageBase64;
3525
- });
3526
- }
3965
+ // Convert to the required image format
3966
+ let imageDataUrl = imageBase64;
3967
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3968
+ // Redraw the exported data URL when the browser returned a different image format.
3969
+ imageDataUrl = await new Promise((resolve, reject) => {
3970
+ const imageElement = new window.Image();
3971
+ imageElement.crossOrigin = "Anonymous";
3972
+ imageElement.onload = () => {
3973
+ try {
3974
+ const offscreenCanvas = document.createElement('canvas');
3975
+ offscreenCanvas.width = imageElement.width;
3976
+ offscreenCanvas.height = imageElement.height;
3977
+ const context = offscreenCanvas.getContext('2d');
3978
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
3979
+ context.drawImage(imageElement, 0, 0);
3980
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3981
+ resolve(convertedDataUrl);
3982
+ } catch (error) { reject(error); }
3983
+ };
3984
+ imageElement.onerror = reject;
3985
+ imageElement.src = imageBase64;
3986
+ });
3987
+ }
3527
3988
 
3528
- // Convert the final data URL to a File with the requested MIME type.
3529
- const bytes = this._decodeBase64Payload(imageDataUrl.split(',')[1]);
3530
- const mime = `image/${safeFileType}`;
3531
- return new File([bytes], fileName, { type: mime });
3989
+ // Convert the final data URL to a File with the requested MIME type.
3990
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3991
+ const mime = `image/${safeFileType}`;
3992
+ return new File([bytes], fileName, { type: mime });
3993
+ } finally {
3994
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3995
+ }
3532
3996
  }
3533
3997
 
3534
3998
  _clearMaskPlacementMemory() {
@@ -3538,7 +4002,7 @@ function ensureFabric() {
3538
4002
  this._lastMaskInitialWidth = null;
3539
4003
  }
3540
4004
 
3541
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
4005
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
3542
4006
  this._reportError(message, error);
3543
4007
 
3544
4008
  if (this._cropRect && this.canvas) this._removeCropRect();
@@ -3551,7 +4015,7 @@ function ensureFabric() {
3551
4015
 
3552
4016
  if (beforeJson) {
3553
4017
  try {
3554
- await this.loadFromState(beforeJson);
4018
+ await this.loadFromState(beforeJson, options);
3555
4019
  } catch (restoreError) {
3556
4020
  this._reportError('applyCrop: rollback failed', restoreError);
3557
4021
  }
@@ -3596,6 +4060,54 @@ function ensureFabric() {
3596
4060
  this._cropHandlers = [];
3597
4061
  }
3598
4062
 
4063
+ _getCropRectContentBounds(cropRect) {
4064
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
4065
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
4066
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
4067
+ return {
4068
+ left: Number(cropRect.left) || 0,
4069
+ top: Number(cropRect.top) || 0,
4070
+ width,
4071
+ height
4072
+ };
4073
+ }
4074
+
4075
+ _getCropRectRawBounds(cropRect) {
4076
+ if (!cropRect) return { left: NaN, top: NaN, width: NaN, height: NaN };
4077
+ return {
4078
+ left: Number(cropRect.left),
4079
+ top: Number(cropRect.top),
4080
+ width: Number(cropRect.width) * Math.abs(Number(cropRect.scaleX)),
4081
+ height: Number(cropRect.height) * Math.abs(Number(cropRect.scaleY))
4082
+ };
4083
+ }
4084
+
4085
+ _isValidCropRegion(cropBounds, imageBounds) {
4086
+ if (!cropBounds || !imageBounds) return false;
4087
+ const left = Number(cropBounds.left);
4088
+ const top = Number(cropBounds.top);
4089
+ const width = Number(cropBounds.width);
4090
+ const height = Number(cropBounds.height);
4091
+ const imageLeft = Number(imageBounds.left);
4092
+ const imageTop = Number(imageBounds.top);
4093
+ const imageWidth = Number(imageBounds.width);
4094
+ const imageHeight = Number(imageBounds.height);
4095
+ if (![left, top, width, height, imageLeft, imageTop, imageWidth, imageHeight].every(Number.isFinite)) return false;
4096
+ if (width <= 0 || height <= 0 || imageWidth <= 0 || imageHeight <= 0) return false;
4097
+
4098
+ const right = left + width;
4099
+ const bottom = top + height;
4100
+ const imageRight = imageLeft + imageWidth;
4101
+ const imageBottom = imageTop + imageHeight;
4102
+ const overlapsImage = left < imageRight && right > imageLeft && top < imageBottom && bottom > imageTop;
4103
+ if (!overlapsImage) return false;
4104
+
4105
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
4106
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
4107
+ if (!Number.isFinite(canvasWidth) || !Number.isFinite(canvasHeight) || canvasWidth <= 0 || canvasHeight <= 0) return false;
4108
+ return left < canvasWidth && right > 0 && top < canvasHeight && bottom > 0;
4109
+ }
4110
+
3599
4111
  /**
3600
4112
  * Enters crop mode by creating a resizable crop rectangle above the base image.
3601
4113
  *
@@ -3607,6 +4119,10 @@ function ensureFabric() {
3607
4119
  */
3608
4120
  enterCropMode() {
3609
4121
  if (!this.canvas || !this.originalImage || this._cropMode) return;
4122
+ if (this._isApplyingCrop) {
4123
+ this._reportWarning('enterCropMode ignored because a crop is already being applied');
4124
+ return;
4125
+ }
3610
4126
  if (!this._canMutateNow('enterCropMode')) return;
3611
4127
  if (!this.isImageLoaded()) return;
3612
4128
  this._removeCropRect();
@@ -3626,14 +4142,19 @@ function ensureFabric() {
3626
4142
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
3627
4143
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
3628
4144
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
3629
- const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
3630
- const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
4145
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
4146
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
3631
4147
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3632
4148
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3633
4149
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3634
4150
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3635
4151
  const width = minCropWidth;
3636
4152
  const height = minCropHeight;
4153
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
4154
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
4155
+ this._cropRotationWarningEmitted = true;
4156
+ this._reportWarning('crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported');
4157
+ }
3637
4158
 
3638
4159
  // Visual style for the temporary crop rectangle.
3639
4160
  const cropRect = new fabric.Rect({
@@ -3645,8 +4166,8 @@ function ensureFabric() {
3645
4166
  strokeWidth: 1,
3646
4167
  strokeUniform: true,
3647
4168
  selectable: true,
3648
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3649
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
4169
+ hasRotatingPoint: false,
4170
+ lockRotation: true,
3650
4171
  cornerSize: 8,
3651
4172
  objectCaching: false,
3652
4173
  originX: 'left',
@@ -3689,7 +4210,7 @@ function ensureFabric() {
3689
4210
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3690
4211
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3691
4212
  cropRect.setCoords();
3692
- const cropBounds = cropRect.getBoundingRect(true, true);
4213
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3693
4214
  const imageLeft = Number(imageBounds.left) || 0;
3694
4215
  const imageTop = Number(imageBounds.top) || 0;
3695
4216
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3741,6 +4262,10 @@ function ensureFabric() {
3741
4262
  * @public
3742
4263
  */
3743
4264
  cancelCrop() {
4265
+ if (this._isApplyingCrop) {
4266
+ this._reportWarning('cancelCrop ignored because a crop is already being applied');
4267
+ return;
4268
+ }
3744
4269
  if (!this.canvas || !this._cropMode) return;
3745
4270
  this._removeCropRect();
3746
4271
  this._restoreCropObjectState();
@@ -3767,11 +4292,26 @@ function ensureFabric() {
3767
4292
  */
3768
4293
  async applyCrop() {
3769
4294
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
4295
+ if (this._isApplyingCrop) {
4296
+ this._reportWarning('applyCrop ignored because a crop is already being applied');
4297
+ return;
4298
+ }
3770
4299
  this._assertIdleForOperation('applyCrop');
4300
+ this._isApplyingCrop = true;
4301
+ const operationToken = this._beginBusyOperation('applyCrop');
4302
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3771
4303
 
4304
+ try {
3772
4305
  // Fabric does not update control coordinates automatically after programmatic transforms.
3773
4306
  this._cropRect.setCoords();
3774
- const rectBounds = this._cropRect.getBoundingRect(true, true);
4307
+ this.originalImage.setCoords();
4308
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
4309
+ const rawCropBounds = this._getCropRectRawBounds(this._cropRect);
4310
+ if (!this._isValidCropRegion(rawCropBounds, imageBounds)) {
4311
+ this._reportWarning('applyCrop: crop region is invalid');
4312
+ return;
4313
+ }
4314
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3775
4315
 
3776
4316
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3777
4317
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
@@ -3786,7 +4326,13 @@ function ensureFabric() {
3786
4326
  beforeJson = null;
3787
4327
  }
3788
4328
  if (!beforeJson) {
3789
- this.cancelCrop();
4329
+ this._removeCropRect();
4330
+ this._cropMode = false;
4331
+ this.canvas.selection = !!this._prevSelectionSetting;
4332
+ this._prevSelectionSetting = undefined;
4333
+ this.canvas.discardActiveObject();
4334
+ this._updateUI();
4335
+ this.canvas.renderAll();
3790
4336
  return;
3791
4337
  }
3792
4338
 
@@ -3817,7 +4363,7 @@ function ensureFabric() {
3817
4363
  this.canvas.renderAll();
3818
4364
  }
3819
4365
  } catch (error) {
3820
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
4366
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error, internalOptions);
3821
4367
  return;
3822
4368
  }
3823
4369
 
@@ -3838,13 +4384,13 @@ function ensureFabric() {
3838
4384
  format: 'jpeg'
3839
4385
  });
3840
4386
  } catch (error) {
3841
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
4387
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error, internalOptions);
3842
4388
  return;
3843
4389
  }
3844
4390
 
3845
4391
  // Load the cropped image as the new base image.
3846
4392
  try {
3847
- await this.loadImage(croppedBase64, { resetMaskCounter: false });
4393
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3848
4394
  if (preservedMasks.length) {
3849
4395
  preservedMasks.forEach(mask => {
3850
4396
  this._rebindMaskEvents(mask);
@@ -3857,7 +4403,7 @@ function ensureFabric() {
3857
4403
  this.canvas.renderAll();
3858
4404
  }
3859
4405
  } catch (error) {
3860
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
4406
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error, internalOptions);
3861
4407
  return;
3862
4408
  }
3863
4409
 
@@ -3879,6 +4425,10 @@ function ensureFabric() {
3879
4425
  // Refresh UI state after crop completion.
3880
4426
  this._updateUI();
3881
4427
  this.canvas.renderAll();
4428
+ } finally {
4429
+ this._isApplyingCrop = false;
4430
+ this._endBusyOperation(operationToken);
4431
+ }
3882
4432
  }
3883
4433
 
3884
4434
 
@@ -3913,10 +4463,12 @@ function ensureFabric() {
3913
4463
  const isBusy = this.isBusy();
3914
4464
 
3915
4465
  if (isInCropMode) {
3916
- // Disable all controls except the crop action buttons while crop mode is active.
4466
+ // Disable operation controls while keeping canvas interaction and viewport scrolling available.
4467
+ const cropInteractionKeys = new Set(['canvas', 'canvasContainer', 'imagePlaceholder', 'imgPlaceholder']);
3917
4468
  for (const key of Object.keys(this.elements || {})) {
3918
4469
  const element = this._getElement(key);
3919
4470
  if (!element) continue;
4471
+ if (cropInteractionKeys.has(key)) continue;
3920
4472
  if (key === 'applyCropButton' || key === 'cancelCropButton' || key === 'applyCropBtn' || key === 'cancelCropBtn') {
3921
4473
  this._setDisabled(key, false);
3922
4474
  } else {
@@ -3956,9 +4508,44 @@ function ensureFabric() {
3956
4508
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3957
4509
  * @private
3958
4510
  */
4511
+ _rememberElementDisabledState(key, element) {
4512
+ if (!element) return;
4513
+ if (!this._elementOriginalDisabledState) this._elementOriginalDisabledState = new Map();
4514
+ if (this._elementOriginalDisabledState.has(key)) return;
4515
+ this._elementOriginalDisabledState.set(key, {
4516
+ element,
4517
+ hasDisabledProperty: 'disabled' in element,
4518
+ disabled: ('disabled' in element) ? !!element.disabled : undefined,
4519
+ ariaDisabled: element.getAttribute ? element.getAttribute('aria-disabled') : null,
4520
+ pointerEvents: element.style ? (element.style.pointerEvents || '') : ''
4521
+ });
4522
+ }
4523
+
4524
+ _restoreElementDisabledStates() {
4525
+ if (!this._elementOriginalDisabledState) return;
4526
+ for (const state of this._elementOriginalDisabledState.values()) {
4527
+ const element = state && state.element;
4528
+ if (!element) continue;
4529
+ try {
4530
+ if (state.hasDisabledProperty && 'disabled' in element) {
4531
+ element.disabled = !!state.disabled;
4532
+ }
4533
+ if (element.getAttribute && element.setAttribute && element.removeAttribute) {
4534
+ if (state.ariaDisabled === null) {
4535
+ element.removeAttribute('aria-disabled');
4536
+ } else {
4537
+ element.setAttribute('aria-disabled', state.ariaDisabled);
4538
+ }
4539
+ }
4540
+ if (element.style) element.style.pointerEvents = state.pointerEvents || '';
4541
+ } catch (error) { void error; }
4542
+ }
4543
+ }
4544
+
3959
4545
  _setDisabled(key, disabled) {
3960
4546
  const element = this._getElement(key);
3961
4547
  if (!element) return;
4548
+ this._rememberElementDisabledState(key, element);
3962
4549
  if ('disabled' in element) {
3963
4550
  element.disabled = !!disabled;
3964
4551
  return;
@@ -3988,7 +4575,6 @@ function ensureFabric() {
3988
4575
  * @private
3989
4576
  */
3990
4577
  _updatePlaceholderStatus() {
3991
- if (!this.options.showPlaceholder) return;
3992
4578
  this._setPlaceholderVisible(!this.originalImage);
3993
4579
  }
3994
4580
 
@@ -3999,10 +4585,11 @@ function ensureFabric() {
3999
4585
  * @private
4000
4586
  */
4001
4587
  _setPlaceholderVisible(show) {
4002
- if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
4588
+ const shouldShowPlaceholder = !!show && this.options.showPlaceholder !== false;
4589
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, shouldShowPlaceholder);
4003
4590
  const canvasVisibilityElement = this._getCanvasVisibilityElement();
4004
4591
  if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
4005
- this._setElementVisible(canvasVisibilityElement, !show);
4592
+ this._setElementVisible(canvasVisibilityElement, !shouldShowPlaceholder);
4006
4593
  }
4007
4594
  }
4008
4595
 
@@ -4088,6 +4675,9 @@ function ensureFabric() {
4088
4675
  } catch (error) { void error; }
4089
4676
 
4090
4677
  if (this._cropRect) this._removeCropRect();
4678
+ this._isApplyingCrop = false;
4679
+
4680
+ try { this._restoreElementDisabledStates(); } catch (error) { void error; }
4091
4681
 
4092
4682
  if (this.containerElement && this._containerOriginalOverflow) {
4093
4683
  try { this._restoreContainerOverflowState(); } catch (error) { void error; }
@@ -4125,6 +4715,7 @@ function ensureFabric() {
4125
4715
  this._handlersByElementKey = {};
4126
4716
  this._elementCache = {};
4127
4717
  this._elementOriginalPointerEvents = new Map();
4718
+ this._elementOriginalDisabledState = new Map();
4128
4719
  this._clearMaskPlacementMemory();
4129
4720
  this.originalImage = null;
4130
4721
  this.baseImageScale = 1;
@@ -4133,6 +4724,7 @@ function ensureFabric() {
4133
4724
  this.isAnimating = false;
4134
4725
  this._isLoading = false;
4135
4726
  this._cropMode = false;
4727
+ this._isApplyingCrop = false;
4136
4728
  this._cropRect = null;
4137
4729
  this._cropHandlers = [];
4138
4730
  this._cropPrevEvented = null;
@@ -4168,6 +4760,7 @@ function ensureFabric() {
4168
4760
 
4169
4761
  /**
4170
4762
  * @callback HistoryTaskCallback
4763
+ * @param {Object} [options] - Internal operation options passed by the editor.
4171
4764
  * @returns {void|Promise<void>} Result of a history operation.
4172
4765
  */
4173
4766
 
@@ -4304,12 +4897,13 @@ function ensureFabric() {
4304
4897
  * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
4305
4898
  */
4306
4899
  constructor(maxSize = 50) {
4900
+ const numericMaxSize = Number(maxSize);
4307
4901
  /** @type {Array<Command>} */
4308
4902
  this.history = [];
4309
4903
  /** @type {number} */
4310
4904
  this.currentIndex = -1;
4311
4905
  /** @type {number} */
4312
- this.maxSize = maxSize;
4906
+ this.maxSize = Number.isFinite(numericMaxSize) && numericMaxSize > 0 ? Math.floor(numericMaxSize) : 50;
4313
4907
  /** @type {Promise<void>} */
4314
4908
  this.pending = Promise.resolve();
4315
4909
  }
@@ -4389,11 +4983,11 @@ function ensureFabric() {
4389
4983
  *
4390
4984
  * @returns {Promise<void>} Resolves after the undo task completes.
4391
4985
  */
4392
- undo() {
4986
+ undo(options = {}) {
4393
4987
  return this.enqueue(async () => {
4394
4988
  if (this.currentIndex >= 0) {
4395
4989
  const index = this.currentIndex;
4396
- await this.history[index].undo();
4990
+ await this.history[index].undo(options);
4397
4991
  this.currentIndex = index - 1;
4398
4992
  }
4399
4993
  });
@@ -4404,11 +4998,11 @@ function ensureFabric() {
4404
4998
  *
4405
4999
  * @returns {Promise<void>} Resolves after the redo task completes.
4406
5000
  */
4407
- redo() {
5001
+ redo(options = {}) {
4408
5002
  return this.enqueue(async () => {
4409
5003
  if (this.currentIndex < this.history.length - 1) {
4410
5004
  const index = this.currentIndex + 1;
4411
- await this.history[index].execute();
5005
+ await this.history[index].execute(options);
4412
5006
  this.currentIndex = index;
4413
5007
  }
4414
5008
  });