@bensitu/image-editor 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.5.0
4
+ * @version 1.5.1
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -132,6 +132,7 @@ function ensureFabric() {
132
132
  * @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
133
133
  * @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
134
134
  * @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
135
+ * @param {number} [options.maxExportPixels=50000000] - Maximum output pixels allowed per export.
135
136
  * @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
136
137
  * @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
137
138
  * @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
@@ -202,6 +203,7 @@ function ensureFabric() {
202
203
  imageLoadTimeoutMs: 30000,
203
204
 
204
205
  exportMultiplier: 1,
206
+ maxExportPixels: 50000000,
205
207
  exportImageAreaByDefault: true,
206
208
 
207
209
  defaultMaskWidth: 50,
@@ -283,6 +285,8 @@ function ensureFabric() {
283
285
  this._activeAnimationRejectors = new Set();
284
286
  this._disposed = false;
285
287
  this._initialized = false;
288
+ this._deprecatedElementKeyWarnings = new Set();
289
+ this._cropRotationWarningEmitted = false;
286
290
 
287
291
  this.onImageLoaded = typeof this.options.onImageLoaded === 'function' ? this.options.onImageLoaded : null;
288
292
 
@@ -356,7 +360,13 @@ function ensureFabric() {
356
360
  * });
357
361
  */
358
362
  init(idMap = {}) {
359
- if (!this._fabricLoaded) return;
363
+ if (!this._fabricLoaded) {
364
+ this._fabricLoaded = !!ensureFabric();
365
+ if (!this._fabricLoaded) {
366
+ this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
367
+ return;
368
+ }
369
+ }
360
370
  if (this._initialized || this.canvas) this.dispose();
361
371
  this._disposed = false;
362
372
  this._initialized = true;
@@ -371,7 +381,6 @@ function ensureFabric() {
371
381
  this._containerOriginalOverflow = null;
372
382
  this._lastContainerViewportSize = null;
373
383
  this._canvasElementOriginalStyle = null;
374
- this._deprecatedElementKeyWarnings = new Set();
375
384
 
376
385
  const defaults = {
377
386
  canvas: 'fabricCanvas',
@@ -410,6 +419,7 @@ function ensureFabric() {
410
419
  redoButton: 'redoButton',
411
420
  redoBtn: null,
412
421
  imageInput: 'imageInput',
422
+ uploadArea: null,
413
423
  enterCropModeButton: 'enterCropModeButton',
414
424
  cropBtn: null,
415
425
  applyCropButton: 'applyCropButton',
@@ -429,7 +439,8 @@ function ensureFabric() {
429
439
 
430
440
  // Auto-load initial image if provided
431
441
  if (this.options.initialImageBase64) {
432
- this.loadImage(this.options.initialImageBase64);
442
+ this.loadImage(this.options.initialImageBase64)
443
+ .catch(error => this._reportError('initialImageBase64 could not be loaded', error));
433
444
  } else {
434
445
  this._updatePlaceholderStatus();
435
446
  }
@@ -660,13 +671,14 @@ function ensureFabric() {
660
671
  this._captureContainerOverflowState();
661
672
 
662
673
  const shouldPreserveScroll = options.preserveScroll === true;
663
- if (this.options.coverImageToCanvas) {
674
+ const layoutMode = this._getImageLayoutMode();
675
+ if (layoutMode === 'cover') {
664
676
  this.containerElement.style.overflow = 'scroll';
665
677
  if (!shouldPreserveScroll) {
666
678
  this.containerElement.scrollLeft = 0;
667
679
  this.containerElement.scrollTop = 0;
668
680
  }
669
- } else if (this.options.fitImageToCanvas) {
681
+ } else if (layoutMode === 'fit') {
670
682
  this.containerElement.style.overflow = 'auto';
671
683
  if (!shouldPreserveScroll) {
672
684
  this.containerElement.scrollLeft = 0;
@@ -838,6 +850,13 @@ function ensureFabric() {
838
850
  );
839
851
  }
840
852
 
853
+ _getImageLayoutMode() {
854
+ if (this.options.fitImageToCanvas) return 'fit';
855
+ if (this.options.coverImageToCanvas) return 'cover';
856
+ if (this.options.expandCanvasToImage) return 'expand';
857
+ return 'contain';
858
+ }
859
+
841
860
  /**
842
861
  * Loads a base64 data URL into the Fabric canvas as the base image.
843
862
  *
@@ -851,14 +870,21 @@ function ensureFabric() {
851
870
  if (!this._fabricLoaded) return;
852
871
  if (!this.canvas || this._disposed) return;
853
872
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
873
+ options = options || {};
854
874
  this._assertIdleForOperation('loadImage', options);
855
875
 
856
- this._isLoading = true;
857
- this._updateUI();
858
- this._warnOnImageLayoutOptionConflict();
859
- const transaction = this._captureLoadImageTransaction();
876
+ const isNestedOperation = this._isOwnInternalOperation(options);
877
+ const operationToken = isNestedOperation
878
+ ? this._getInternalOperationToken(options)
879
+ : this._beginBusyOperation('loadImage');
880
+ let transaction = null;
860
881
 
861
882
  try {
883
+ this._isLoading = true;
884
+ this._updateUI();
885
+ this._warnOnImageLayoutOptionConflict();
886
+ transaction = this._captureLoadImageTransaction();
887
+
862
888
  const imageElement = await this._createImageElement(imageBase64);
863
889
  if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
864
890
 
@@ -906,8 +932,9 @@ function ensureFabric() {
906
932
  const viewport = this._getContainerViewportSize();
907
933
  const minWidth = viewport.width;
908
934
  const minHeight = viewport.height;
935
+ const layoutMode = this._getImageLayoutMode();
909
936
 
910
- if (this.options.fitImageToCanvas) {
937
+ if (layoutMode === 'fit') {
911
938
  const canvasWidth = Math.max(1, minWidth - 1);
912
939
  const canvasHeight = Math.max(1, minHeight - 1);
913
940
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -915,13 +942,13 @@ function ensureFabric() {
915
942
  fabricImage.set({ left: 0, top: 0 });
916
943
  fabricImage.scale(fitScale);
917
944
  this.baseImageScale = fabricImage.scaleX || 1;
918
- } else if (this.options.coverImageToCanvas) {
945
+ } else if (layoutMode === 'cover') {
919
946
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
920
947
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
921
948
  fabricImage.set({ left: 0, top: 0 });
922
949
  fabricImage.scale(layout.scale);
923
950
  this.baseImageScale = fabricImage.scaleX || 1;
924
- } else if (this.options.expandCanvasToImage) {
951
+ } else if (layoutMode === 'expand') {
925
952
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
926
953
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
927
954
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -957,10 +984,14 @@ function ensureFabric() {
957
984
 
958
985
  this._notifyImageLoaded();
959
986
  } catch (error) {
960
- await this._rollbackLoadImageTransaction(transaction);
987
+ await this._rollbackLoadImageTransaction(
988
+ transaction,
989
+ this._withInternalOperationOptions(operationToken)
990
+ );
961
991
  throw error;
962
992
  } finally {
963
993
  this._isLoading = false;
994
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
964
995
  if (!this._disposed && this.canvas) this._updateUI();
965
996
  }
966
997
  }
@@ -1092,13 +1123,13 @@ function ensureFabric() {
1092
1123
  };
1093
1124
  }
1094
1125
 
1095
- async _rollbackLoadImageTransaction(transaction) {
1126
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
1096
1127
  if (!transaction || !this.canvas || this._disposed) return;
1097
1128
  let didRestoreCanvasState = false;
1098
1129
  let didFailCanvasRestore = false;
1099
1130
  try {
1100
1131
  if (transaction.canvasState) {
1101
- await this.loadFromState(transaction.canvasState);
1132
+ await this.loadFromState(transaction.canvasState, options);
1102
1133
  didRestoreCanvasState = true;
1103
1134
  }
1104
1135
  } catch (error) {
@@ -1413,10 +1444,18 @@ function ensureFabric() {
1413
1444
 
1414
1445
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1415
1446
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1447
+ const safetyMargin = this._getScrollSafetyMargin();
1448
+ const layoutMode = this._getImageLayoutMode();
1449
+ const shouldReserveNoScrollbarMargin = layoutMode === 'fit' || layoutMode === 'cover';
1450
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1451
+ const margin = hasOppositeScrollbar ? safetyMargin : (shouldReserveNoScrollbarMargin ? 1 : 0);
1452
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1453
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1454
+ };
1416
1455
 
1417
1456
  return {
1418
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1419
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1457
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1458
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1420
1459
  viewportWidth: effectiveWidth,
1421
1460
  viewportHeight: effectiveHeight,
1422
1461
  hasHorizontal,
@@ -1549,6 +1588,50 @@ function ensureFabric() {
1549
1588
  }
1550
1589
  }
1551
1590
 
1591
+ _getSerializableStateObjects() {
1592
+ if (!this.canvas) return [];
1593
+ return this.canvas.getObjects().filter(object => !object.isCropRect && !object.maskLabel);
1594
+ }
1595
+
1596
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1597
+ if (!Array.isArray(serializedObjects)) return;
1598
+ const fabricObjects = this._getSerializableStateObjects();
1599
+ const numericProperties = [
1600
+ 'left',
1601
+ 'top',
1602
+ 'width',
1603
+ 'height',
1604
+ 'scaleX',
1605
+ 'scaleY',
1606
+ 'angle',
1607
+ 'skewX',
1608
+ 'skewY',
1609
+ 'cropX',
1610
+ 'cropY',
1611
+ 'radius',
1612
+ 'rx',
1613
+ 'ry',
1614
+ 'strokeWidth'
1615
+ ];
1616
+
1617
+ serializedObjects.forEach((serializedObject, index) => {
1618
+ const fabricObject = fabricObjects[index];
1619
+ if (!serializedObject || !fabricObject) return;
1620
+
1621
+ numericProperties.forEach(property => {
1622
+ const numericValue = Number(fabricObject[property]);
1623
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1624
+ });
1625
+
1626
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1627
+ serializedObject.points = fabricObject.points.map(point => ({
1628
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1629
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1630
+ }));
1631
+ }
1632
+ });
1633
+ }
1634
+
1552
1635
  _restoreMaskControls(mask) {
1553
1636
  if (!mask) return;
1554
1637
 
@@ -1598,6 +1681,7 @@ function ensureFabric() {
1598
1681
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1599
1682
  if (Array.isArray(jsonObject.objects)) {
1600
1683
  jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
1684
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1601
1685
  }
1602
1686
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1603
1687
  return JSON.stringify(jsonObject);
@@ -1680,6 +1764,13 @@ function ensureFabric() {
1680
1764
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1681
1765
  }
1682
1766
 
1767
+ _hasScaledImageEdge(axis) {
1768
+ if (!this.originalImage) return false;
1769
+ const scale = Number(axis === 'y' ? this.originalImage.scaleY : this.originalImage.scaleX);
1770
+ if (!Number.isFinite(scale)) return false;
1771
+ return Math.abs(scale - 1) > 0.01;
1772
+ }
1773
+
1683
1774
  _getPartialExportEdges(bounds) {
1684
1775
  if (!bounds) return null;
1685
1776
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1689,8 +1780,8 @@ function ensureFabric() {
1689
1780
  return {
1690
1781
  left: this._hasFractionalCanvasEdge(bounds.left),
1691
1782
  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))
1783
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge('x'),
1784
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge('y')
1694
1785
  };
1695
1786
  }
1696
1787
 
@@ -1756,7 +1847,8 @@ function ensureFabric() {
1756
1847
  * @private
1757
1848
  */
1758
1849
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
1759
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1850
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1851
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1760
1852
  const safeFormat = this._normalizeImageFormat(format);
1761
1853
  const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1762
1854
  let regionDataUrl = this.canvas.toDataURL({
@@ -1774,6 +1866,30 @@ function ensureFabric() {
1774
1866
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1775
1867
  }
1776
1868
 
1869
+ _getSafeExportMultiplier(multiplier) {
1870
+ const numericMultiplier = Number(multiplier);
1871
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1872
+ throw new Error('Export multiplier must be a finite positive number');
1873
+ }
1874
+ return Math.max(1, numericMultiplier);
1875
+ }
1876
+
1877
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1878
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1879
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1880
+ const outputWidth = Math.ceil(width * safeMultiplier);
1881
+ const outputHeight = Math.ceil(height * safeMultiplier);
1882
+ const outputPixels = outputWidth * outputHeight;
1883
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1884
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0
1885
+ ? Math.floor(configuredMaxPixels)
1886
+ : 50000000;
1887
+
1888
+ if (outputPixels > maxPixels) {
1889
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1890
+ }
1891
+ }
1892
+
1777
1893
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1778
1894
  const imageElement = await this._createImageElement(dataUrl);
1779
1895
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1826,6 +1942,7 @@ function ensureFabric() {
1826
1942
 
1827
1943
  _decodeBase64Payload(base64Payload) {
1828
1944
  const payload = String(base64Payload || '');
1945
+ if (!payload) throw new Error('Data URL base64 payload is empty');
1829
1946
  if (typeof atob === 'function') {
1830
1947
  return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
1831
1948
  }
@@ -1835,6 +1952,14 @@ function ensureFabric() {
1835
1952
  throw new Error('Base64 decoding is unavailable');
1836
1953
  }
1837
1954
 
1955
+ _decodeDataUrlPayload(dataUrl) {
1956
+ const match = String(dataUrl || '').match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
1957
+ if (!match || !match[2]) {
1958
+ throw new Error('Export produced an invalid or empty base64 data URL');
1959
+ }
1960
+ return this._decodeBase64Payload(match[2]);
1961
+ }
1962
+
1838
1963
  /**
1839
1964
  * Gets the top-left corner coordinates of the given object.
1840
1965
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1953,13 +2078,49 @@ function ensureFabric() {
1953
2078
  const currentHeight = this.canvas.getHeight();
1954
2079
  let requiredWidth = currentWidth;
1955
2080
  let requiredHeight = currentHeight;
1956
- fabricObjects.forEach(fabricObject => {
2081
+ const layoutMode = this._getImageLayoutMode();
2082
+ const usesScrollableFitBounds = layoutMode === 'fit' || layoutMode === 'cover';
2083
+ let contentWidth = 0;
2084
+ let contentHeight = 0;
2085
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1957
2086
  if (!fabricObject) return;
1958
2087
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1959
2088
  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));
2089
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
2090
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
2091
+ contentWidth = Math.max(contentWidth, right);
2092
+ contentHeight = Math.max(contentHeight, bottom);
2093
+ return { right, bottom };
2094
+ };
2095
+ fabricObjects.forEach(fabricObject => {
2096
+ const bounds = includeObjectBounds(fabricObject, padding);
2097
+ if (!bounds) return;
2098
+ requiredWidth = Math.max(requiredWidth, bounds.right);
2099
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1962
2100
  });
2101
+ if (usesScrollableFitBounds) {
2102
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
2103
+ this.canvas.getObjects().forEach(object => {
2104
+ if (object && object.maskId) includeObjectBounds(object, padding);
2105
+ });
2106
+
2107
+ const contentSize = this._getScrollableCanvasSize(
2108
+ Math.max(1, contentWidth),
2109
+ Math.max(1, contentHeight)
2110
+ );
2111
+
2112
+ const newWidth = contentSize.hasHorizontal
2113
+ ? Math.max(currentWidth, contentSize.width)
2114
+ : contentSize.width;
2115
+ const newHeight = contentSize.hasVertical
2116
+ ? Math.max(currentHeight, contentSize.height)
2117
+ : contentSize.height;
2118
+
2119
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
2120
+ this._setCanvasSizeInt(newWidth, newHeight);
2121
+ }
2122
+ return;
2123
+ }
1963
2124
  let minWidth = 0;
1964
2125
  let minHeight = 0;
1965
2126
  if (this.containerElement) {
@@ -1979,16 +2140,65 @@ function ensureFabric() {
1979
2140
  }
1980
2141
  }
1981
2142
 
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);
2143
+ _captureImageDisplayBounds() {
2144
+ if (!this.originalImage || !this.canvas) return null;
2145
+ this.originalImage.setCoords();
2146
+ const bounds = this.originalImage.getBoundingRect(true, true);
2147
+ const width = Number(bounds && bounds.width);
2148
+ const height = Number(bounds && bounds.height);
2149
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
2150
+
2151
+ return {
2152
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
2153
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
2154
+ width,
2155
+ height
2156
+ };
2157
+ }
2158
+
2159
+ _restoreImageDisplayBounds(displayBounds) {
2160
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
2161
+ const imageWidth = Number(this.originalImage.width);
2162
+ const imageHeight = Number(this.originalImage.height);
2163
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
2164
+
2165
+ const scaleX = Number(displayBounds.width) / imageWidth;
2166
+ const scaleY = Number(displayBounds.height) / imageHeight;
2167
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
2168
+
2169
+ const left = Number(displayBounds.left) || 0;
2170
+ const top = Number(displayBounds.top) || 0;
2171
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
2172
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
2173
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
2174
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
2175
+ const layoutMode = this._getImageLayoutMode();
2176
+ if (layoutMode === 'fit' || layoutMode === 'cover') {
2177
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
2178
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
2179
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
2180
+ }
2181
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
2182
+ this._setCanvasSizeInt(
2183
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
2184
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
2185
+ );
2186
+ }
2187
+
2188
+ this.originalImage.set({
2189
+ originX: 'left',
2190
+ originY: 'top',
2191
+ left,
2192
+ top,
2193
+ scaleX,
2194
+ scaleY
2195
+ });
2196
+ this.originalImage.setCoords();
2197
+ this.baseImageScale = scaleX;
2198
+ this.currentScale = 1;
2199
+ this.currentRotation = Number(this.originalImage.angle) || 0;
2200
+ this._updateInputs();
2201
+ this.canvas.renderAll();
1992
2202
  }
1993
2203
 
1994
2204
  /**
@@ -2004,7 +2214,14 @@ function ensureFabric() {
2004
2214
  } catch (error) {
2005
2215
  return Promise.reject(error);
2006
2216
  }
2007
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
2217
+ return this.animationQueue.add(async () => {
2218
+ const operationToken = this._beginBusyOperation('scaleImage');
2219
+ try {
2220
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
2221
+ } finally {
2222
+ this._endBusyOperation(operationToken);
2223
+ }
2224
+ })
2008
2225
  .finally(() => {
2009
2226
  if (!this._disposed && this.canvas) this._updateUI();
2010
2227
  });
@@ -2056,7 +2273,7 @@ function ensureFabric() {
2056
2273
  if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
2057
2274
  throw new Error(`${operationName} cannot run while crop mode is active`);
2058
2275
  }
2059
- if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
2276
+ if ((this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) && !isOwnInternalOperation) {
2060
2277
  throw new Error(`${operationName} cannot run while an animation is running`);
2061
2278
  }
2062
2279
  if (this._isLoading && !isOwnInternalOperation) {
@@ -2179,7 +2396,7 @@ function ensureFabric() {
2179
2396
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2180
2397
 
2181
2398
  this._updateInputs();
2182
- if (saveHistory) this.saveState();
2399
+ if (saveHistory) this.saveState(options);
2183
2400
  } finally {
2184
2401
  if (didStartAnimation) {
2185
2402
  this.isAnimating = false;
@@ -2202,7 +2419,14 @@ function ensureFabric() {
2202
2419
  } catch (error) {
2203
2420
  return Promise.reject(error);
2204
2421
  }
2205
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
2422
+ return this.animationQueue.add(async () => {
2423
+ const operationToken = this._beginBusyOperation('rotateImage');
2424
+ try {
2425
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2426
+ } finally {
2427
+ this._endBusyOperation(operationToken);
2428
+ }
2429
+ })
2206
2430
  .finally(() => {
2207
2431
  if (!this._disposed && this.canvas) this._updateUI();
2208
2432
  });
@@ -2253,7 +2477,7 @@ function ensureFabric() {
2253
2477
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
2254
2478
 
2255
2479
  this._updateInputs();
2256
- if (saveHistory) this.saveState();
2480
+ if (saveHistory) this.saveState(options);
2257
2481
  didCompleteRotation = true;
2258
2482
  } finally {
2259
2483
  if (!didCompleteRotation && !this._disposed && image) {
@@ -2282,19 +2506,22 @@ function ensureFabric() {
2282
2506
  }
2283
2507
 
2284
2508
  return this.animationQueue.add(async () => {
2509
+ const operationToken = this._beginBusyOperation('resetImageTransform');
2285
2510
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
2286
2511
  try {
2287
- await this._scaleImageImpl(1, { saveHistory: false });
2288
- await this._rotateImageImpl(0, { saveHistory: false });
2512
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2513
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2289
2514
  const after = this._captureCanvasStateOrThrow('resetImageTransform');
2290
2515
  this._pushStateTransition(before, after);
2291
2516
  } catch (error) {
2292
2517
  try {
2293
- await this.loadFromState(before);
2518
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
2294
2519
  } catch (restoreError) {
2295
2520
  this._reportError('resetImageTransform rollback failed', restoreError);
2296
2521
  }
2297
2522
  throw error;
2523
+ } finally {
2524
+ this._endBusyOperation(operationToken);
2298
2525
  }
2299
2526
  }).finally(() => {
2300
2527
  if (!this._disposed && this.canvas) this._updateUI();
@@ -2321,8 +2548,13 @@ function ensureFabric() {
2321
2548
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
2322
2549
  * @public
2323
2550
  */
2324
- loadFromState(serializedState) {
2551
+ loadFromState(serializedState, options = {}) {
2325
2552
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2553
+ try {
2554
+ this._assertIdleForOperation('loadFromState', options);
2555
+ } catch (error) {
2556
+ return Promise.reject(error);
2557
+ }
2326
2558
  if (this._cropMode || this._cropRect) {
2327
2559
  this._removeCropRect();
2328
2560
  this._restoreCropObjectState();
@@ -2508,9 +2740,17 @@ function ensureFabric() {
2508
2740
  * @returns {void}
2509
2741
  * @public
2510
2742
  */
2511
- saveState() {
2743
+ saveState(options = {}) {
2512
2744
  if (!this.canvas) return;
2513
2745
 
2746
+ try {
2747
+ this._assertIdleForOperation('saveState', options);
2748
+ } catch (error) {
2749
+ this._reportError('saveState blocked', error);
2750
+ this._updateUI();
2751
+ return;
2752
+ }
2753
+
2514
2754
  try {
2515
2755
  const after = this._captureCanvasStateOrThrow('saveState');
2516
2756
  const before = this._lastSnapshot || after;
@@ -2518,14 +2758,14 @@ function ensureFabric() {
2518
2758
  let executedOnce = false;
2519
2759
 
2520
2760
  const command = new Command(
2521
- () => {
2761
+ (commandOptions = {}) => {
2522
2762
  if (executedOnce) {
2523
- return this.loadFromState(after);
2763
+ return this.loadFromState(after, commandOptions);
2524
2764
  }
2525
2765
  executedOnce = true;
2526
2766
  return undefined;
2527
2767
  },
2528
- () => this.loadFromState(before)
2768
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2529
2769
  );
2530
2770
 
2531
2771
  this.historyManager.execute(command);
@@ -2557,8 +2797,8 @@ function ensureFabric() {
2557
2797
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2558
2798
 
2559
2799
  const command = new Command(
2560
- () => this.loadFromState(after),
2561
- () => this.loadFromState(before)
2800
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2801
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2562
2802
  );
2563
2803
  this.historyManager.push(command);
2564
2804
  this._lastSnapshot = after;
@@ -2572,8 +2812,17 @@ function ensureFabric() {
2572
2812
  * @public
2573
2813
  */
2574
2814
  undo() {
2575
- return this.historyManager.undo()
2815
+ try {
2816
+ this._assertIdleForOperation('undo');
2817
+ } catch (error) {
2818
+ return Promise.reject(error);
2819
+ }
2820
+ const operationToken = this._beginBusyOperation('undo');
2821
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken))
2576
2822
  .then(() => { this._updateUI(); })
2823
+ .finally(() => {
2824
+ this._endBusyOperation(operationToken);
2825
+ })
2577
2826
  .catch(error => {
2578
2827
  this._reportError('undo failed', error);
2579
2828
  throw error;
@@ -2587,8 +2836,17 @@ function ensureFabric() {
2587
2836
  * @public
2588
2837
  */
2589
2838
  redo() {
2590
- return this.historyManager.redo()
2839
+ try {
2840
+ this._assertIdleForOperation('redo');
2841
+ } catch (error) {
2842
+ return Promise.reject(error);
2843
+ }
2844
+ const operationToken = this._beginBusyOperation('redo');
2845
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken))
2591
2846
  .then(() => { this._updateUI(); })
2847
+ .finally(() => {
2848
+ this._endBusyOperation(operationToken);
2849
+ })
2592
2850
  .catch(error => {
2593
2851
  this._reportError('redo failed', error);
2594
2852
  throw error;
@@ -2712,21 +2970,49 @@ function ensureFabric() {
2712
2970
  return value != null ? value : fallback;
2713
2971
  };
2714
2972
 
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');
2973
+ const rejectInvalidMask = (message, error = null) => {
2974
+ this._reportWarning(`createMask: ${message}`, error);
2975
+ return null;
2976
+ };
2977
+
2978
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
2979
+ const resolvedValue = resolveValue(value, fallback, axis);
2980
+ const numericValue = Number(resolvedValue);
2981
+ if (!Number.isFinite(numericValue)) {
2982
+ throw new Error(`${fieldName} must be a finite number`);
2983
+ }
2984
+ if (constraints.positive && numericValue <= 0) {
2985
+ throw new Error(`${fieldName} must be greater than 0`);
2986
+ }
2987
+ if (constraints.nonNegative && numericValue < 0) {
2988
+ throw new Error(`${fieldName} must be 0 or greater`);
2989
+ }
2990
+ return numericValue;
2991
+ };
2992
+
2993
+ try {
2994
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, 'width', 'gap', { nonNegative: true });
2995
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, 'width', 'width', { positive: true });
2996
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, 'height', 'height', { positive: true });
2997
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, 'width', 'angle');
2998
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, 'width', 'alpha')));
2999
+
3000
+ if (maskConfig.left === undefined && this._lastMask) {
3001
+ const previousMask = this._lastMask;
3002
+ if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
3003
+ const previousBounds = typeof previousMask.getBoundingRect === 'function'
3004
+ ? previousMask.getBoundingRect(true, true)
3005
+ : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
3006
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
3007
+ top = Math.round(previousBounds.top ?? firstOffset);
3008
+ } else {
3009
+ left = resolveNumber(maskConfig.left, firstOffset, 'width', 'left');
3010
+ top = resolveNumber(maskConfig.top, firstOffset, 'height', 'top');
3011
+ }
3012
+ } catch (error) {
3013
+ return rejectInvalidMask('invalid numeric configuration', error);
2726
3014
  }
2727
3015
 
2728
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
2729
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
2730
3016
  maskConfig.left = left;
2731
3017
  maskConfig.top = top;
2732
3018
 
@@ -2736,9 +3022,14 @@ function ensureFabric() {
2736
3022
  } else {
2737
3023
  switch (shapeType) {
2738
3024
  case 'circle':
3025
+ try {
3026
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min', 'radius', { positive: true });
3027
+ } catch (error) {
3028
+ return rejectInvalidMask('invalid circle radius', error);
3029
+ }
2739
3030
  mask = new fabric.Circle({
2740
3031
  left, top,
2741
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
3032
+ radius: maskConfig.radius,
2742
3033
  fill: maskConfig.color,
2743
3034
  opacity: maskConfig.alpha,
2744
3035
  angle: maskConfig.angle,
@@ -2746,10 +3037,16 @@ function ensureFabric() {
2746
3037
  });
2747
3038
  break;
2748
3039
  case 'ellipse':
3040
+ try {
3041
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, 'width', 'rx', { positive: true });
3042
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, 'height', 'ry', { positive: true });
3043
+ } catch (error) {
3044
+ return rejectInvalidMask('invalid ellipse radius', error);
3045
+ }
2749
3046
  mask = new fabric.Ellipse({
2750
3047
  left, top,
2751
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
2752
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
3048
+ rx: maskConfig.rx,
3049
+ ry: maskConfig.ry,
2753
3050
  fill: maskConfig.color,
2754
3051
  opacity: maskConfig.alpha,
2755
3052
  angle: maskConfig.angle,
@@ -2758,11 +3055,20 @@ function ensureFabric() {
2758
3055
  break;
2759
3056
  case 'polygon': {
2760
3057
  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) });
3058
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
3059
+ return rejectInvalidMask('polygon masks require at least three points');
3060
+ }
3061
+ try {
3062
+ polygonPoints = polygonPoints.map(point => {
3063
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
3064
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
3065
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3066
+ throw new Error('polygon point coordinates must be finite numbers');
3067
+ }
3068
+ return { x, y };
3069
+ });
3070
+ } catch (error) {
3071
+ return rejectInvalidMask('invalid polygon points', error);
2766
3072
  }
2767
3073
  mask = new fabric.Polygon(polygonPoints, {
2768
3074
  left, top,
@@ -2775,10 +3081,16 @@ function ensureFabric() {
2775
3081
  }
2776
3082
  case 'rect':
2777
3083
  default:
3084
+ try {
3085
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, 'width', 'rx', { nonNegative: true });
3086
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, 'height', 'ry', { nonNegative: true });
3087
+ } catch (error) {
3088
+ return rejectInvalidMask('invalid rectangle corner radius', error);
3089
+ }
2778
3090
  mask = new fabric.Rect({
2779
3091
  left, top,
2780
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
2781
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
3092
+ width: maskConfig.width,
3093
+ height: maskConfig.height,
2782
3094
  fill: maskConfig.color,
2783
3095
  opacity: maskConfig.alpha,
2784
3096
  angle: maskConfig.angle,
@@ -2819,12 +3131,12 @@ function ensureFabric() {
2819
3131
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2820
3132
  });
2821
3133
  this._rebindMaskEvents(mask);
2822
- this._expandCanvasToFitObject(mask);
3134
+ this._expandCanvasToFitObjects([mask]);
2823
3135
 
2824
3136
  // Store placement values so the next mask can be positioned beside this one.
2825
3137
  this._lastMaskInitialLeft = left;
2826
3138
  this._lastMaskInitialTop = top;
2827
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
3139
+ this._lastMaskInitialWidth = maskConfig.width;
2828
3140
 
2829
3141
  const maskId = ++this.maskCounter;
2830
3142
  mask.set({
@@ -3274,6 +3586,7 @@ function ensureFabric() {
3274
3586
  this._assertIdleForOperation('mergeMasks');
3275
3587
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3276
3588
  if (!masks.length) return;
3589
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
3277
3590
  const beforeJson = this._serializeCanvasState();
3278
3591
  const operationToken = this._beginBusyOperation('mergeMasks');
3279
3592
 
@@ -3294,12 +3607,13 @@ function ensureFabric() {
3294
3607
  preserveScroll: true,
3295
3608
  resetMaskCounter: false
3296
3609
  }));
3610
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
3297
3611
  const afterJson = this._serializeCanvasState();
3298
3612
  this._pushStateTransition(beforeJson, afterJson);
3299
3613
  } catch (error) {
3300
3614
  this._reportError('merge error', error);
3301
3615
  try {
3302
- await this.loadFromState(beforeJson);
3616
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
3303
3617
  } catch (restoreError) {
3304
3618
  this._reportError('mergeMasks rollback failed', restoreError);
3305
3619
  }
@@ -3361,13 +3675,19 @@ function ensureFabric() {
3361
3675
  */
3362
3676
  async exportImageBase64(options = {}) {
3363
3677
  if (!this.originalImage) throw new Error('No image loaded');
3678
+ options = options || {};
3364
3679
  this._assertIdleForOperation('exportImageBase64', options);
3680
+ const isNestedOperation = this._isOwnInternalOperation(options);
3681
+ const operationToken = isNestedOperation
3682
+ ? this._getInternalOperationToken(options)
3683
+ : this._beginBusyOperation('exportImageBase64');
3365
3684
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
3366
3685
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
3367
3686
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
3368
3687
  const format = this._normalizeImageFormat(options.fileType || options.format);
3369
3688
 
3370
- if (!exportImageArea) {
3689
+ try {
3690
+ if (!exportImageArea) {
3371
3691
  const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
3372
3692
  const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
3373
3693
  const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
@@ -3399,15 +3719,15 @@ function ensureFabric() {
3399
3719
  this._restoreActiveObjectBackup(activeObjectBackup);
3400
3720
  this.canvas.renderAll();
3401
3721
  }
3402
- }
3722
+ }
3403
3723
 
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();
3724
+ // Render masks as export shapes without mutating their editable styles.
3725
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
3726
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3727
+ const labelBackups = this._captureMaskLabelBackups(masks);
3728
+ const activeObjectBackup = this._captureActiveObjectBackup();
3409
3729
 
3410
- try {
3730
+ try {
3411
3731
  // Labels are UI overlays and should not be part of the flattened export.
3412
3732
  masks.forEach(mask => this._removeLabelForMask(mask));
3413
3733
  this.canvas.discardActiveObject();
@@ -3438,6 +3758,9 @@ function ensureFabric() {
3438
3758
  this._restoreActiveObjectBackup(activeObjectBackup);
3439
3759
  this.canvas.renderAll();
3440
3760
  }
3761
+ } finally {
3762
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3763
+ }
3441
3764
  }
3442
3765
 
3443
3766
  /**
@@ -3471,7 +3794,12 @@ function ensureFabric() {
3471
3794
  */
3472
3795
  async exportImageFile(options = {}) {
3473
3796
  if (!this.originalImage) throw new Error('No image loaded');
3474
- this._assertIdleForOperation('exportImageFile');
3797
+ options = options || {};
3798
+ this._assertIdleForOperation('exportImageFile', options);
3799
+ const isNestedOperation = this._isOwnInternalOperation(options);
3800
+ const operationToken = isNestedOperation
3801
+ ? this._getInternalOperationToken(options)
3802
+ : this._beginBusyOperation('exportImageFile');
3475
3803
  const {
3476
3804
  mergeMask = true,
3477
3805
  fileType = 'jpeg',
@@ -3483,52 +3811,56 @@ function ensureFabric() {
3483
3811
  const safeFileType = this._normalizeImageFormat(fileType);
3484
3812
  const normalizedQuality = this._normalizeQuality(quality);
3485
3813
 
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
- }
3814
+ try {
3815
+ // Generate the data URL in the requested export mode.
3816
+ let imageBase64;
3817
+ if (mergeMask) {
3818
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3819
+ exportImageArea: true,
3820
+ multiplier,
3821
+ quality: normalizedQuality,
3822
+ fileType: safeFileType
3823
+ }));
3824
+ } else {
3825
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3826
+ exportImageArea: false,
3827
+ multiplier,
3828
+ quality: normalizedQuality,
3829
+ fileType: safeFileType
3830
+ }));
3831
+ }
3503
3832
 
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
- }
3833
+ // Convert to the required image format
3834
+ let imageDataUrl = imageBase64;
3835
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3836
+ // Redraw the exported data URL when the browser returned a different image format.
3837
+ imageDataUrl = await new Promise((resolve, reject) => {
3838
+ const imageElement = new window.Image();
3839
+ imageElement.crossOrigin = "Anonymous";
3840
+ imageElement.onload = () => {
3841
+ try {
3842
+ const offscreenCanvas = document.createElement('canvas');
3843
+ offscreenCanvas.width = imageElement.width;
3844
+ offscreenCanvas.height = imageElement.height;
3845
+ const context = offscreenCanvas.getContext('2d');
3846
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
3847
+ context.drawImage(imageElement, 0, 0);
3848
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3849
+ resolve(convertedDataUrl);
3850
+ } catch (error) { reject(error); }
3851
+ };
3852
+ imageElement.onerror = reject;
3853
+ imageElement.src = imageBase64;
3854
+ });
3855
+ }
3527
3856
 
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 });
3857
+ // Convert the final data URL to a File with the requested MIME type.
3858
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3859
+ const mime = `image/${safeFileType}`;
3860
+ return new File([bytes], fileName, { type: mime });
3861
+ } finally {
3862
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3863
+ }
3532
3864
  }
3533
3865
 
3534
3866
  _clearMaskPlacementMemory() {
@@ -3538,7 +3870,7 @@ function ensureFabric() {
3538
3870
  this._lastMaskInitialWidth = null;
3539
3871
  }
3540
3872
 
3541
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
3873
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
3542
3874
  this._reportError(message, error);
3543
3875
 
3544
3876
  if (this._cropRect && this.canvas) this._removeCropRect();
@@ -3551,7 +3883,7 @@ function ensureFabric() {
3551
3883
 
3552
3884
  if (beforeJson) {
3553
3885
  try {
3554
- await this.loadFromState(beforeJson);
3886
+ await this.loadFromState(beforeJson, options);
3555
3887
  } catch (restoreError) {
3556
3888
  this._reportError('applyCrop: rollback failed', restoreError);
3557
3889
  }
@@ -3596,6 +3928,18 @@ function ensureFabric() {
3596
3928
  this._cropHandlers = [];
3597
3929
  }
3598
3930
 
3931
+ _getCropRectContentBounds(cropRect) {
3932
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
3933
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
3934
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
3935
+ return {
3936
+ left: Number(cropRect.left) || 0,
3937
+ top: Number(cropRect.top) || 0,
3938
+ width,
3939
+ height
3940
+ };
3941
+ }
3942
+
3599
3943
  /**
3600
3944
  * Enters crop mode by creating a resizable crop rectangle above the base image.
3601
3945
  *
@@ -3626,14 +3970,19 @@ function ensureFabric() {
3626
3970
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
3627
3971
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
3628
3972
  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));
3973
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
3974
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
3631
3975
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3632
3976
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3633
3977
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3634
3978
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3635
3979
  const width = minCropWidth;
3636
3980
  const height = minCropHeight;
3981
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
3982
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
3983
+ this._cropRotationWarningEmitted = true;
3984
+ this._reportWarning('crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported');
3985
+ }
3637
3986
 
3638
3987
  // Visual style for the temporary crop rectangle.
3639
3988
  const cropRect = new fabric.Rect({
@@ -3645,8 +3994,8 @@ function ensureFabric() {
3645
3994
  strokeWidth: 1,
3646
3995
  strokeUniform: true,
3647
3996
  selectable: true,
3648
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3649
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
3997
+ hasRotatingPoint: false,
3998
+ lockRotation: true,
3650
3999
  cornerSize: 8,
3651
4000
  objectCaching: false,
3652
4001
  originX: 'left',
@@ -3689,7 +4038,7 @@ function ensureFabric() {
3689
4038
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3690
4039
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3691
4040
  cropRect.setCoords();
3692
- const cropBounds = cropRect.getBoundingRect(true, true);
4041
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3693
4042
  const imageLeft = Number(imageBounds.left) || 0;
3694
4043
  const imageTop = Number(imageBounds.top) || 0;
3695
4044
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3768,10 +4117,13 @@ function ensureFabric() {
3768
4117
  async applyCrop() {
3769
4118
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3770
4119
  this._assertIdleForOperation('applyCrop');
4120
+ const operationToken = this._beginBusyOperation('applyCrop');
4121
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3771
4122
 
4123
+ try {
3772
4124
  // Fabric does not update control coordinates automatically after programmatic transforms.
3773
4125
  this._cropRect.setCoords();
3774
- const rectBounds = this._cropRect.getBoundingRect(true, true);
4126
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3775
4127
 
3776
4128
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3777
4129
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
@@ -3817,7 +4169,7 @@ function ensureFabric() {
3817
4169
  this.canvas.renderAll();
3818
4170
  }
3819
4171
  } catch (error) {
3820
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
4172
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error, internalOptions);
3821
4173
  return;
3822
4174
  }
3823
4175
 
@@ -3838,13 +4190,13 @@ function ensureFabric() {
3838
4190
  format: 'jpeg'
3839
4191
  });
3840
4192
  } catch (error) {
3841
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
4193
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error, internalOptions);
3842
4194
  return;
3843
4195
  }
3844
4196
 
3845
4197
  // Load the cropped image as the new base image.
3846
4198
  try {
3847
- await this.loadImage(croppedBase64, { resetMaskCounter: false });
4199
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3848
4200
  if (preservedMasks.length) {
3849
4201
  preservedMasks.forEach(mask => {
3850
4202
  this._rebindMaskEvents(mask);
@@ -3857,7 +4209,7 @@ function ensureFabric() {
3857
4209
  this.canvas.renderAll();
3858
4210
  }
3859
4211
  } catch (error) {
3860
- await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
4212
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error, internalOptions);
3861
4213
  return;
3862
4214
  }
3863
4215
 
@@ -3879,6 +4231,9 @@ function ensureFabric() {
3879
4231
  // Refresh UI state after crop completion.
3880
4232
  this._updateUI();
3881
4233
  this.canvas.renderAll();
4234
+ } finally {
4235
+ this._endBusyOperation(operationToken);
4236
+ }
3882
4237
  }
3883
4238
 
3884
4239
 
@@ -4168,6 +4523,7 @@ function ensureFabric() {
4168
4523
 
4169
4524
  /**
4170
4525
  * @callback HistoryTaskCallback
4526
+ * @param {Object} [options] - Internal operation options passed by the editor.
4171
4527
  * @returns {void|Promise<void>} Result of a history operation.
4172
4528
  */
4173
4529
 
@@ -4389,11 +4745,11 @@ function ensureFabric() {
4389
4745
  *
4390
4746
  * @returns {Promise<void>} Resolves after the undo task completes.
4391
4747
  */
4392
- undo() {
4748
+ undo(options = {}) {
4393
4749
  return this.enqueue(async () => {
4394
4750
  if (this.currentIndex >= 0) {
4395
4751
  const index = this.currentIndex;
4396
- await this.history[index].undo();
4752
+ await this.history[index].undo(options);
4397
4753
  this.currentIndex = index - 1;
4398
4754
  }
4399
4755
  });
@@ -4404,11 +4760,11 @@ function ensureFabric() {
4404
4760
  *
4405
4761
  * @returns {Promise<void>} Resolves after the redo task completes.
4406
4762
  */
4407
- redo() {
4763
+ redo(options = {}) {
4408
4764
  return this.enqueue(async () => {
4409
4765
  if (this.currentIndex < this.history.length - 1) {
4410
4766
  const index = this.currentIndex + 1;
4411
- await this.history[index].execute();
4767
+ await this.history[index].execute(options);
4412
4768
  this.currentIndex = index;
4413
4769
  }
4414
4770
  });