@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.
@@ -5,7 +5,7 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.5.0
8
+ * @version 1.5.1
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -77,6 +77,7 @@ var ImageEditor = class {
77
77
  downsampleMimeType: null,
78
78
  imageLoadTimeoutMs: 3e4,
79
79
  exportMultiplier: 1,
80
+ maxExportPixels: 5e7,
80
81
  exportImageAreaByDefault: true,
81
82
  defaultMaskWidth: 50,
82
83
  defaultMaskHeight: 80,
@@ -146,6 +147,8 @@ var ImageEditor = class {
146
147
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
148
  this._disposed = false;
148
149
  this._initialized = false;
150
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
151
+ this._cropRotationWarningEmitted = false;
149
152
  this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
150
153
  this.animationQueue = new AnimationQueue();
151
154
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -210,7 +213,13 @@ var ImageEditor = class {
210
213
  * });
211
214
  */
212
215
  init(idMap = {}) {
213
- if (!this._fabricLoaded) return;
216
+ if (!this._fabricLoaded) {
217
+ this._fabricLoaded = !!ensureFabric();
218
+ if (!this._fabricLoaded) {
219
+ this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
220
+ return;
221
+ }
222
+ }
214
223
  if (this._initialized || this.canvas) this.dispose();
215
224
  this._disposed = false;
216
225
  this._initialized = true;
@@ -225,7 +234,6 @@ var ImageEditor = class {
225
234
  this._containerOriginalOverflow = null;
226
235
  this._lastContainerViewportSize = null;
227
236
  this._canvasElementOriginalStyle = null;
228
- this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
229
237
  const defaults = {
230
238
  canvas: "fabricCanvas",
231
239
  canvasContainer: null,
@@ -264,6 +272,7 @@ var ImageEditor = class {
264
272
  redoButton: "redoButton",
265
273
  redoBtn: null,
266
274
  imageInput: "imageInput",
275
+ uploadArea: null,
267
276
  enterCropModeButton: "enterCropModeButton",
268
277
  cropBtn: null,
269
278
  applyCropButton: "applyCropButton",
@@ -279,7 +288,7 @@ var ImageEditor = class {
279
288
  this._updateMaskList();
280
289
  this._updateUI();
281
290
  if (this.options.initialImageBase64) {
282
- this.loadImage(this.options.initialImageBase64);
291
+ this.loadImage(this.options.initialImageBase64).catch((error) => this._reportError("initialImageBase64 could not be loaded", error));
283
292
  } else {
284
293
  this._updatePlaceholderStatus();
285
294
  }
@@ -480,13 +489,14 @@ var ImageEditor = class {
480
489
  if (!this.containerElement || !this.containerElement.style) return;
481
490
  this._captureContainerOverflowState();
482
491
  const shouldPreserveScroll = options.preserveScroll === true;
483
- if (this.options.coverImageToCanvas) {
492
+ const layoutMode = this._getImageLayoutMode();
493
+ if (layoutMode === "cover") {
484
494
  this.containerElement.style.overflow = "scroll";
485
495
  if (!shouldPreserveScroll) {
486
496
  this.containerElement.scrollLeft = 0;
487
497
  this.containerElement.scrollTop = 0;
488
498
  }
489
- } else if (this.options.fitImageToCanvas) {
499
+ } else if (layoutMode === "fit") {
490
500
  this.containerElement.style.overflow = "auto";
491
501
  if (!shouldPreserveScroll) {
492
502
  this.containerElement.scrollLeft = 0;
@@ -637,6 +647,12 @@ var ImageEditor = class {
637
647
  `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
638
648
  );
639
649
  }
650
+ _getImageLayoutMode() {
651
+ if (this.options.fitImageToCanvas) return "fit";
652
+ if (this.options.coverImageToCanvas) return "cover";
653
+ if (this.options.expandCanvasToImage) return "expand";
654
+ return "contain";
655
+ }
640
656
  /**
641
657
  * Loads a base64 data URL into the Fabric canvas as the base image.
642
658
  *
@@ -650,12 +666,16 @@ var ImageEditor = class {
650
666
  if (!this._fabricLoaded) return;
651
667
  if (!this.canvas || this._disposed) return;
652
668
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
669
+ options = options || {};
653
670
  this._assertIdleForOperation("loadImage", options);
654
- this._isLoading = true;
655
- this._updateUI();
656
- this._warnOnImageLayoutOptionConflict();
657
- const transaction = this._captureLoadImageTransaction();
671
+ const isNestedOperation = this._isOwnInternalOperation(options);
672
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("loadImage");
673
+ let transaction = null;
658
674
  try {
675
+ this._isLoading = true;
676
+ this._updateUI();
677
+ this._warnOnImageLayoutOptionConflict();
678
+ transaction = this._captureLoadImageTransaction();
659
679
  const imageElement = await this._createImageElement(imageBase64);
660
680
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
661
681
  let loadSource = imageBase64;
@@ -695,7 +715,8 @@ var ImageEditor = class {
695
715
  const viewport = this._getContainerViewportSize();
696
716
  const minWidth = viewport.width;
697
717
  const minHeight = viewport.height;
698
- if (this.options.fitImageToCanvas) {
718
+ const layoutMode = this._getImageLayoutMode();
719
+ if (layoutMode === "fit") {
699
720
  const canvasWidth = Math.max(1, minWidth - 1);
700
721
  const canvasHeight = Math.max(1, minHeight - 1);
701
722
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -703,13 +724,13 @@ var ImageEditor = class {
703
724
  fabricImage.set({ left: 0, top: 0 });
704
725
  fabricImage.scale(fitScale);
705
726
  this.baseImageScale = fabricImage.scaleX || 1;
706
- } else if (this.options.coverImageToCanvas) {
727
+ } else if (layoutMode === "cover") {
707
728
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
708
729
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
709
730
  fabricImage.set({ left: 0, top: 0 });
710
731
  fabricImage.scale(layout.scale);
711
732
  this.baseImageScale = fabricImage.scaleX || 1;
712
- } else if (this.options.expandCanvasToImage) {
733
+ } else if (layoutMode === "expand") {
713
734
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
714
735
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
715
736
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -740,10 +761,14 @@ var ImageEditor = class {
740
761
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
741
762
  this._notifyImageLoaded();
742
763
  } catch (error) {
743
- await this._rollbackLoadImageTransaction(transaction);
764
+ await this._rollbackLoadImageTransaction(
765
+ transaction,
766
+ this._withInternalOperationOptions(operationToken)
767
+ );
744
768
  throw error;
745
769
  } finally {
746
770
  this._isLoading = false;
771
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
747
772
  if (!this._disposed && this.canvas) this._updateUI();
748
773
  }
749
774
  }
@@ -856,13 +881,13 @@ var ImageEditor = class {
856
881
  canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
857
882
  };
858
883
  }
859
- async _rollbackLoadImageTransaction(transaction) {
884
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
860
885
  if (!transaction || !this.canvas || this._disposed) return;
861
886
  let didRestoreCanvasState = false;
862
887
  let didFailCanvasRestore = false;
863
888
  try {
864
889
  if (transaction.canvasState) {
865
- await this.loadFromState(transaction.canvasState);
890
+ await this.loadFromState(transaction.canvasState, options);
866
891
  didRestoreCanvasState = true;
867
892
  }
868
893
  } catch (error) {
@@ -1112,9 +1137,9 @@ var ImageEditor = class {
1112
1137
  }
1113
1138
  _getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
1114
1139
  if (this._hasFixedContainerScrollbars()) {
1115
- const safetyMargin = this._getScrollSafetyMargin();
1116
- const safeWidth = Math.max(1, viewport.width - safetyMargin);
1117
- const safeHeight = Math.max(1, viewport.height - safetyMargin);
1140
+ const safetyMargin2 = this._getScrollSafetyMargin();
1141
+ const safeWidth = Math.max(1, viewport.width - safetyMargin2);
1142
+ const safeHeight = Math.max(1, viewport.height - safetyMargin2);
1118
1143
  return {
1119
1144
  width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
1120
1145
  height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
@@ -1140,9 +1165,17 @@ var ImageEditor = class {
1140
1165
  }
1141
1166
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1142
1167
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1168
+ const safetyMargin = this._getScrollSafetyMargin();
1169
+ const layoutMode = this._getImageLayoutMode();
1170
+ const shouldReserveNoScrollbarMargin = layoutMode === "fit" || layoutMode === "cover";
1171
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1172
+ const margin = hasOppositeScrollbar ? safetyMargin : shouldReserveNoScrollbarMargin ? 1 : 0;
1173
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1174
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1175
+ };
1143
1176
  return {
1144
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1145
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1177
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1178
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1146
1179
  viewportWidth: effectiveWidth,
1147
1180
  viewportHeight: effectiveHeight,
1148
1181
  hasHorizontal,
@@ -1264,6 +1297,45 @@ var ImageEditor = class {
1264
1297
  });
1265
1298
  }
1266
1299
  }
1300
+ _getSerializableStateObjects() {
1301
+ if (!this.canvas) return [];
1302
+ return this.canvas.getObjects().filter((object) => !object.isCropRect && !object.maskLabel);
1303
+ }
1304
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1305
+ if (!Array.isArray(serializedObjects)) return;
1306
+ const fabricObjects = this._getSerializableStateObjects();
1307
+ const numericProperties = [
1308
+ "left",
1309
+ "top",
1310
+ "width",
1311
+ "height",
1312
+ "scaleX",
1313
+ "scaleY",
1314
+ "angle",
1315
+ "skewX",
1316
+ "skewY",
1317
+ "cropX",
1318
+ "cropY",
1319
+ "radius",
1320
+ "rx",
1321
+ "ry",
1322
+ "strokeWidth"
1323
+ ];
1324
+ serializedObjects.forEach((serializedObject, index) => {
1325
+ const fabricObject = fabricObjects[index];
1326
+ if (!serializedObject || !fabricObject) return;
1327
+ numericProperties.forEach((property) => {
1328
+ const numericValue = Number(fabricObject[property]);
1329
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1330
+ });
1331
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1332
+ serializedObject.points = fabricObject.points.map((point) => ({
1333
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1334
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1335
+ }));
1336
+ }
1337
+ });
1338
+ }
1267
1339
  _restoreMaskControls(mask) {
1268
1340
  if (!mask) return;
1269
1341
  const cornerSize = Number(mask.cornerSize);
@@ -1309,6 +1381,7 @@ var ImageEditor = class {
1309
1381
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1310
1382
  if (Array.isArray(jsonObject.objects)) {
1311
1383
  jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
1384
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1312
1385
  }
1313
1386
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1314
1387
  return JSON.stringify(jsonObject);
@@ -1383,6 +1456,12 @@ var ImageEditor = class {
1383
1456
  if (!Number.isFinite(numericValue)) return false;
1384
1457
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1385
1458
  }
1459
+ _hasScaledImageEdge(axis) {
1460
+ if (!this.originalImage) return false;
1461
+ const scale = Number(axis === "y" ? this.originalImage.scaleY : this.originalImage.scaleX);
1462
+ if (!Number.isFinite(scale)) return false;
1463
+ return Math.abs(scale - 1) > 0.01;
1464
+ }
1386
1465
  _getPartialExportEdges(bounds) {
1387
1466
  if (!bounds) return null;
1388
1467
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1391,8 +1470,8 @@ var ImageEditor = class {
1391
1470
  return {
1392
1471
  left: this._hasFractionalCanvasEdge(bounds.left),
1393
1472
  top: this._hasFractionalCanvasEdge(bounds.top),
1394
- right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1395
- bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1473
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge("x"),
1474
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge("y")
1396
1475
  };
1397
1476
  }
1398
1477
  async _sealPartialTransparentEdges(dataUrl, edges) {
@@ -1452,7 +1531,8 @@ var ImageEditor = class {
1452
1531
  * @private
1453
1532
  */
1454
1533
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1455
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1534
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1535
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1456
1536
  const safeFormat = this._normalizeImageFormat(format);
1457
1537
  const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1458
1538
  let regionDataUrl = this.canvas.toDataURL({
@@ -1468,6 +1548,25 @@ var ImageEditor = class {
1468
1548
  if (safeFormat !== "jpeg") return regionDataUrl;
1469
1549
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1470
1550
  }
1551
+ _getSafeExportMultiplier(multiplier) {
1552
+ const numericMultiplier = Number(multiplier);
1553
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1554
+ throw new Error("Export multiplier must be a finite positive number");
1555
+ }
1556
+ return Math.max(1, numericMultiplier);
1557
+ }
1558
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1559
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1560
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1561
+ const outputWidth = Math.ceil(width * safeMultiplier);
1562
+ const outputHeight = Math.ceil(height * safeMultiplier);
1563
+ const outputPixels = outputWidth * outputHeight;
1564
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1565
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0 ? Math.floor(configuredMaxPixels) : 5e7;
1566
+ if (outputPixels > maxPixels) {
1567
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1568
+ }
1569
+ }
1471
1570
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1472
1571
  const imageElement = await this._createImageElement(dataUrl);
1473
1572
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1512,6 +1611,7 @@ var ImageEditor = class {
1512
1611
  }
1513
1612
  _decodeBase64Payload(base64Payload) {
1514
1613
  const payload = String(base64Payload || "");
1614
+ if (!payload) throw new Error("Data URL base64 payload is empty");
1515
1615
  if (typeof atob === "function") {
1516
1616
  return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
1517
1617
  }
@@ -1520,6 +1620,13 @@ var ImageEditor = class {
1520
1620
  }
1521
1621
  throw new Error("Base64 decoding is unavailable");
1522
1622
  }
1623
+ _decodeDataUrlPayload(dataUrl) {
1624
+ const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
1625
+ if (!match || !match[2]) {
1626
+ throw new Error("Export produced an invalid or empty base64 data URL");
1627
+ }
1628
+ return this._decodeBase64Payload(match[2]);
1629
+ }
1523
1630
  /**
1524
1631
  * Gets the top-left corner coordinates of the given object.
1525
1632
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1629,13 +1736,42 @@ var ImageEditor = class {
1629
1736
  const currentHeight = this.canvas.getHeight();
1630
1737
  let requiredWidth = currentWidth;
1631
1738
  let requiredHeight = currentHeight;
1632
- fabricObjects.forEach((fabricObject) => {
1739
+ const layoutMode = this._getImageLayoutMode();
1740
+ const usesScrollableFitBounds = layoutMode === "fit" || layoutMode === "cover";
1741
+ let contentWidth = 0;
1742
+ let contentHeight = 0;
1743
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1633
1744
  if (!fabricObject) return;
1634
1745
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1635
1746
  const boundingRect = fabricObject.getBoundingRect(true, true);
1636
- requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1637
- requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1747
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
1748
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
1749
+ contentWidth = Math.max(contentWidth, right);
1750
+ contentHeight = Math.max(contentHeight, bottom);
1751
+ return { right, bottom };
1752
+ };
1753
+ fabricObjects.forEach((fabricObject) => {
1754
+ const bounds = includeObjectBounds(fabricObject, padding);
1755
+ if (!bounds) return;
1756
+ requiredWidth = Math.max(requiredWidth, bounds.right);
1757
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1638
1758
  });
1759
+ if (usesScrollableFitBounds) {
1760
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
1761
+ this.canvas.getObjects().forEach((object) => {
1762
+ if (object && object.maskId) includeObjectBounds(object, padding);
1763
+ });
1764
+ const contentSize = this._getScrollableCanvasSize(
1765
+ Math.max(1, contentWidth),
1766
+ Math.max(1, contentHeight)
1767
+ );
1768
+ const newWidth2 = contentSize.hasHorizontal ? Math.max(currentWidth, contentSize.width) : contentSize.width;
1769
+ const newHeight2 = contentSize.hasVertical ? Math.max(currentHeight, contentSize.height) : contentSize.height;
1770
+ if (newWidth2 !== currentWidth || newHeight2 !== currentHeight) {
1771
+ this._setCanvasSizeInt(newWidth2, newHeight2);
1772
+ }
1773
+ return;
1774
+ }
1639
1775
  let minWidth = 0;
1640
1776
  let minHeight = 0;
1641
1777
  if (this.containerElement) {
@@ -1653,16 +1789,60 @@ var ImageEditor = class {
1653
1789
  this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
1654
1790
  }
1655
1791
  }
1656
- /**
1657
- * Expands the canvas so one object remains visible after an edit.
1658
- *
1659
- * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1660
- * @param {number} [padding=10] - Extra canvas space after the object edge.
1661
- * @returns {void}
1662
- * @private
1663
- */
1664
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1665
- this._expandCanvasToFitObjects([fabricObject], padding);
1792
+ _captureImageDisplayBounds() {
1793
+ if (!this.originalImage || !this.canvas) return null;
1794
+ this.originalImage.setCoords();
1795
+ const bounds = this.originalImage.getBoundingRect(true, true);
1796
+ const width = Number(bounds && bounds.width);
1797
+ const height = Number(bounds && bounds.height);
1798
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
1799
+ return {
1800
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
1801
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
1802
+ width,
1803
+ height
1804
+ };
1805
+ }
1806
+ _restoreImageDisplayBounds(displayBounds) {
1807
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
1808
+ const imageWidth = Number(this.originalImage.width);
1809
+ const imageHeight = Number(this.originalImage.height);
1810
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
1811
+ const scaleX = Number(displayBounds.width) / imageWidth;
1812
+ const scaleY = Number(displayBounds.height) / imageHeight;
1813
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
1814
+ const left = Number(displayBounds.left) || 0;
1815
+ const top = Number(displayBounds.top) || 0;
1816
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
1817
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
1818
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
1819
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
1820
+ const layoutMode = this._getImageLayoutMode();
1821
+ if (layoutMode === "fit" || layoutMode === "cover") {
1822
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
1823
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
1824
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
1825
+ }
1826
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
1827
+ this._setCanvasSizeInt(
1828
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
1829
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
1830
+ );
1831
+ }
1832
+ this.originalImage.set({
1833
+ originX: "left",
1834
+ originY: "top",
1835
+ left,
1836
+ top,
1837
+ scaleX,
1838
+ scaleY
1839
+ });
1840
+ this.originalImage.setCoords();
1841
+ this.baseImageScale = scaleX;
1842
+ this.currentScale = 1;
1843
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1844
+ this._updateInputs();
1845
+ this.canvas.renderAll();
1666
1846
  }
1667
1847
  /**
1668
1848
  * Scales the original image by a given factor, with animation.
@@ -1677,7 +1857,14 @@ var ImageEditor = class {
1677
1857
  } catch (error) {
1678
1858
  return Promise.reject(error);
1679
1859
  }
1680
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1860
+ return this.animationQueue.add(async () => {
1861
+ const operationToken = this._beginBusyOperation("scaleImage");
1862
+ try {
1863
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
1864
+ } finally {
1865
+ this._endBusyOperation(operationToken);
1866
+ }
1867
+ }).finally(() => {
1681
1868
  if (!this._disposed && this.canvas) this._updateUI();
1682
1869
  });
1683
1870
  }
@@ -1720,7 +1907,7 @@ var ImageEditor = class {
1720
1907
  if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1721
1908
  throw new Error(`${operationName} cannot run while crop mode is active`);
1722
1909
  }
1723
- if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1910
+ if ((this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) && !isOwnInternalOperation) {
1724
1911
  throw new Error(`${operationName} cannot run while an animation is running`);
1725
1912
  }
1726
1913
  if (this._isLoading && !isOwnInternalOperation) {
@@ -1833,7 +2020,7 @@ var ImageEditor = class {
1833
2020
  if (object.maskId) this._syncMaskLabel(object);
1834
2021
  });
1835
2022
  this._updateInputs();
1836
- if (saveHistory) this.saveState();
2023
+ if (saveHistory) this.saveState(options);
1837
2024
  } finally {
1838
2025
  if (didStartAnimation) {
1839
2026
  this.isAnimating = false;
@@ -1855,7 +2042,14 @@ var ImageEditor = class {
1855
2042
  } catch (error) {
1856
2043
  return Promise.reject(error);
1857
2044
  }
1858
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
2045
+ return this.animationQueue.add(async () => {
2046
+ const operationToken = this._beginBusyOperation("rotateImage");
2047
+ try {
2048
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2049
+ } finally {
2050
+ this._endBusyOperation(operationToken);
2051
+ }
2052
+ }).finally(() => {
1859
2053
  if (!this._disposed && this.canvas) this._updateUI();
1860
2054
  });
1861
2055
  }
@@ -1898,7 +2092,7 @@ var ImageEditor = class {
1898
2092
  if (object.maskId) this._syncMaskLabel(object);
1899
2093
  });
1900
2094
  this._updateInputs();
1901
- if (saveHistory) this.saveState();
2095
+ if (saveHistory) this.saveState(options);
1902
2096
  didCompleteRotation = true;
1903
2097
  } finally {
1904
2098
  if (!didCompleteRotation && !this._disposed && image) {
@@ -1925,19 +2119,22 @@ var ImageEditor = class {
1925
2119
  return Promise.reject(error);
1926
2120
  }
1927
2121
  return this.animationQueue.add(async () => {
2122
+ const operationToken = this._beginBusyOperation("resetImageTransform");
1928
2123
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1929
2124
  try {
1930
- await this._scaleImageImpl(1, { saveHistory: false });
1931
- await this._rotateImageImpl(0, { saveHistory: false });
2125
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2126
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
1932
2127
  const after = this._captureCanvasStateOrThrow("resetImageTransform");
1933
2128
  this._pushStateTransition(before, after);
1934
2129
  } catch (error) {
1935
2130
  try {
1936
- await this.loadFromState(before);
2131
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
1937
2132
  } catch (restoreError) {
1938
2133
  this._reportError("resetImageTransform rollback failed", restoreError);
1939
2134
  }
1940
2135
  throw error;
2136
+ } finally {
2137
+ this._endBusyOperation(operationToken);
1941
2138
  }
1942
2139
  }).finally(() => {
1943
2140
  if (!this._disposed && this.canvas) this._updateUI();
@@ -1962,8 +2159,13 @@ var ImageEditor = class {
1962
2159
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1963
2160
  * @public
1964
2161
  */
1965
- loadFromState(serializedState) {
2162
+ loadFromState(serializedState, options = {}) {
1966
2163
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2164
+ try {
2165
+ this._assertIdleForOperation("loadFromState", options);
2166
+ } catch (error) {
2167
+ return Promise.reject(error);
2168
+ }
1967
2169
  if (this._cropMode || this._cropRect) {
1968
2170
  this._removeCropRect();
1969
2171
  this._restoreCropObjectState();
@@ -2120,22 +2322,29 @@ var ImageEditor = class {
2120
2322
  * @returns {void}
2121
2323
  * @public
2122
2324
  */
2123
- saveState() {
2325
+ saveState(options = {}) {
2124
2326
  if (!this.canvas) return;
2327
+ try {
2328
+ this._assertIdleForOperation("saveState", options);
2329
+ } catch (error) {
2330
+ this._reportError("saveState blocked", error);
2331
+ this._updateUI();
2332
+ return;
2333
+ }
2125
2334
  try {
2126
2335
  const after = this._captureCanvasStateOrThrow("saveState");
2127
2336
  const before = this._lastSnapshot || after;
2128
2337
  if (after === before) return;
2129
2338
  let executedOnce = false;
2130
2339
  const command = new Command(
2131
- () => {
2340
+ (commandOptions = {}) => {
2132
2341
  if (executedOnce) {
2133
- return this.loadFromState(after);
2342
+ return this.loadFromState(after, commandOptions);
2134
2343
  }
2135
2344
  executedOnce = true;
2136
2345
  return void 0;
2137
2346
  },
2138
- () => this.loadFromState(before)
2347
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2139
2348
  );
2140
2349
  this.historyManager.execute(command);
2141
2350
  this._lastSnapshot = after;
@@ -2164,8 +2373,8 @@ var ImageEditor = class {
2164
2373
  if (before === after) return;
2165
2374
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2166
2375
  const command = new Command(
2167
- () => this.loadFromState(after),
2168
- () => this.loadFromState(before)
2376
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2377
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2169
2378
  );
2170
2379
  this.historyManager.push(command);
2171
2380
  this._lastSnapshot = after;
@@ -2178,8 +2387,16 @@ var ImageEditor = class {
2178
2387
  * @public
2179
2388
  */
2180
2389
  undo() {
2181
- return this.historyManager.undo().then(() => {
2390
+ try {
2391
+ this._assertIdleForOperation("undo");
2392
+ } catch (error) {
2393
+ return Promise.reject(error);
2394
+ }
2395
+ const operationToken = this._beginBusyOperation("undo");
2396
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken)).then(() => {
2182
2397
  this._updateUI();
2398
+ }).finally(() => {
2399
+ this._endBusyOperation(operationToken);
2183
2400
  }).catch((error) => {
2184
2401
  this._reportError("undo failed", error);
2185
2402
  throw error;
@@ -2192,8 +2409,16 @@ var ImageEditor = class {
2192
2409
  * @public
2193
2410
  */
2194
2411
  redo() {
2195
- return this.historyManager.redo().then(() => {
2412
+ try {
2413
+ this._assertIdleForOperation("redo");
2414
+ } catch (error) {
2415
+ return Promise.reject(error);
2416
+ }
2417
+ const operationToken = this._beginBusyOperation("redo");
2418
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken)).then(() => {
2196
2419
  this._updateUI();
2420
+ }).finally(() => {
2421
+ this._endBusyOperation(operationToken);
2197
2422
  }).catch((error) => {
2198
2423
  this._reportError("redo failed", error);
2199
2424
  throw error;
@@ -2309,18 +2534,43 @@ var ImageEditor = class {
2309
2534
  }
2310
2535
  return value != null ? value : fallback;
2311
2536
  };
2312
- if (maskConfig.left === void 0 && this._lastMask) {
2313
- const previousMask = this._lastMask;
2314
- if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2315
- const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2316
- left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2317
- top = Math.round(previousBounds.top ?? firstOffset);
2318
- } else {
2319
- left = resolveValue(maskConfig.left, firstOffset, "width");
2320
- top = resolveValue(maskConfig.top, firstOffset, "height");
2537
+ const rejectInvalidMask = (message, error = null) => {
2538
+ this._reportWarning(`createMask: ${message}`, error);
2539
+ return null;
2540
+ };
2541
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
2542
+ const resolvedValue = resolveValue(value, fallback, axis);
2543
+ const numericValue = Number(resolvedValue);
2544
+ if (!Number.isFinite(numericValue)) {
2545
+ throw new Error(`${fieldName} must be a finite number`);
2546
+ }
2547
+ if (constraints.positive && numericValue <= 0) {
2548
+ throw new Error(`${fieldName} must be greater than 0`);
2549
+ }
2550
+ if (constraints.nonNegative && numericValue < 0) {
2551
+ throw new Error(`${fieldName} must be 0 or greater`);
2552
+ }
2553
+ return numericValue;
2554
+ };
2555
+ try {
2556
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, "width", "gap", { nonNegative: true });
2557
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, "width", "width", { positive: true });
2558
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, "height", "height", { positive: true });
2559
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, "width", "angle");
2560
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, "width", "alpha")));
2561
+ if (maskConfig.left === void 0 && this._lastMask) {
2562
+ const previousMask = this._lastMask;
2563
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2564
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2565
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2566
+ top = Math.round(previousBounds.top ?? firstOffset);
2567
+ } else {
2568
+ left = resolveNumber(maskConfig.left, firstOffset, "width", "left");
2569
+ top = resolveNumber(maskConfig.top, firstOffset, "height", "top");
2570
+ }
2571
+ } catch (error) {
2572
+ return rejectInvalidMask("invalid numeric configuration", error);
2321
2573
  }
2322
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2323
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
2324
2574
  maskConfig.left = left;
2325
2575
  maskConfig.top = top;
2326
2576
  let mask;
@@ -2329,10 +2579,15 @@ var ImageEditor = class {
2329
2579
  } else {
2330
2580
  switch (shapeType) {
2331
2581
  case "circle":
2582
+ try {
2583
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min", "radius", { positive: true });
2584
+ } catch (error) {
2585
+ return rejectInvalidMask("invalid circle radius", error);
2586
+ }
2332
2587
  mask = new fabric.Circle({
2333
2588
  left,
2334
2589
  top,
2335
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
2590
+ radius: maskConfig.radius,
2336
2591
  fill: maskConfig.color,
2337
2592
  opacity: maskConfig.alpha,
2338
2593
  angle: maskConfig.angle,
@@ -2340,11 +2595,17 @@ var ImageEditor = class {
2340
2595
  });
2341
2596
  break;
2342
2597
  case "ellipse":
2598
+ try {
2599
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, "width", "rx", { positive: true });
2600
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, "height", "ry", { positive: true });
2601
+ } catch (error) {
2602
+ return rejectInvalidMask("invalid ellipse radius", error);
2603
+ }
2343
2604
  mask = new fabric.Ellipse({
2344
2605
  left,
2345
2606
  top,
2346
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
2347
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
2607
+ rx: maskConfig.rx,
2608
+ ry: maskConfig.ry,
2348
2609
  fill: maskConfig.color,
2349
2610
  opacity: maskConfig.alpha,
2350
2611
  angle: maskConfig.angle,
@@ -2353,8 +2614,20 @@ var ImageEditor = class {
2353
2614
  break;
2354
2615
  case "polygon": {
2355
2616
  let polygonPoints = maskConfig.points || [];
2356
- if (Array.isArray(polygonPoints) && polygonPoints.length) {
2357
- polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
2617
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
2618
+ return rejectInvalidMask("polygon masks require at least three points");
2619
+ }
2620
+ try {
2621
+ polygonPoints = polygonPoints.map((point) => {
2622
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
2623
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
2624
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
2625
+ throw new Error("polygon point coordinates must be finite numbers");
2626
+ }
2627
+ return { x, y };
2628
+ });
2629
+ } catch (error) {
2630
+ return rejectInvalidMask("invalid polygon points", error);
2358
2631
  }
2359
2632
  mask = new fabric.Polygon(polygonPoints, {
2360
2633
  left,
@@ -2368,11 +2641,17 @@ var ImageEditor = class {
2368
2641
  }
2369
2642
  case "rect":
2370
2643
  default:
2644
+ try {
2645
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, "width", "rx", { nonNegative: true });
2646
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, "height", "ry", { nonNegative: true });
2647
+ } catch (error) {
2648
+ return rejectInvalidMask("invalid rectangle corner radius", error);
2649
+ }
2371
2650
  mask = new fabric.Rect({
2372
2651
  left,
2373
2652
  top,
2374
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
2375
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
2653
+ width: maskConfig.width,
2654
+ height: maskConfig.height,
2376
2655
  fill: maskConfig.color,
2377
2656
  opacity: maskConfig.alpha,
2378
2657
  angle: maskConfig.angle,
@@ -2410,10 +2689,10 @@ var ImageEditor = class {
2410
2689
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2411
2690
  });
2412
2691
  this._rebindMaskEvents(mask);
2413
- this._expandCanvasToFitObject(mask);
2692
+ this._expandCanvasToFitObjects([mask]);
2414
2693
  this._lastMaskInitialLeft = left;
2415
2694
  this._lastMaskInitialTop = top;
2416
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2695
+ this._lastMaskInitialWidth = maskConfig.width;
2417
2696
  const maskId = ++this.maskCounter;
2418
2697
  mask.set({
2419
2698
  maskId,
@@ -2841,6 +3120,7 @@ var ImageEditor = class {
2841
3120
  this._assertIdleForOperation("mergeMasks");
2842
3121
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2843
3122
  if (!masks.length) return;
3123
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
2844
3124
  const beforeJson = this._serializeCanvasState();
2845
3125
  const operationToken = this._beginBusyOperation("mergeMasks");
2846
3126
  this.canvas.discardActiveObject();
@@ -2859,12 +3139,13 @@ var ImageEditor = class {
2859
3139
  preserveScroll: true,
2860
3140
  resetMaskCounter: false
2861
3141
  }));
3142
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
2862
3143
  const afterJson = this._serializeCanvasState();
2863
3144
  this._pushStateTransition(beforeJson, afterJson);
2864
3145
  } catch (error) {
2865
3146
  this._reportError("merge error", error);
2866
3147
  try {
2867
- await this.loadFromState(beforeJson);
3148
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
2868
3149
  } catch (restoreError) {
2869
3150
  this._reportError("mergeMasks rollback failed", restoreError);
2870
3151
  }
@@ -2921,24 +3202,65 @@ var ImageEditor = class {
2921
3202
  */
2922
3203
  async exportImageBase64(options = {}) {
2923
3204
  if (!this.originalImage) throw new Error("No image loaded");
3205
+ options = options || {};
2924
3206
  this._assertIdleForOperation("exportImageBase64", options);
3207
+ const isNestedOperation = this._isOwnInternalOperation(options);
3208
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageBase64");
2925
3209
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2926
3210
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2927
3211
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
2928
3212
  const format = this._normalizeImageFormat(options.fileType || options.format);
2929
- if (!exportImageArea) {
2930
- const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
2931
- const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
2932
- const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
2933
- const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
2934
- const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
2935
- const activeObjectBackup2 = this._captureActiveObjectBackup();
3213
+ try {
3214
+ if (!exportImageArea) {
3215
+ const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
3216
+ const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
3217
+ const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
3218
+ const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
3219
+ const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
3220
+ const activeObjectBackup2 = this._captureActiveObjectBackup();
3221
+ try {
3222
+ masks2.forEach((mask) => {
3223
+ mask.set({ visible: false });
3224
+ });
3225
+ this.canvas.discardActiveObject();
3226
+ this.canvas.renderAll();
3227
+ this.originalImage.setCoords();
3228
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
3229
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
3230
+ return await this._exportCanvasRegionToDataURL({
3231
+ ...exportRegion,
3232
+ multiplier,
3233
+ quality,
3234
+ format,
3235
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
3236
+ });
3237
+ } finally {
3238
+ maskVisibilityBackups.forEach((backup) => {
3239
+ try {
3240
+ backup.object.set({ visible: backup.visible });
3241
+ } catch (error) {
3242
+ void error;
3243
+ }
3244
+ });
3245
+ this._restoreMaskExportBackups(maskStyleBackups2);
3246
+ this._restoreMaskLabelBackups(labelBackups2);
3247
+ this._restoreActiveObjectBackup(activeObjectBackup2);
3248
+ this.canvas.renderAll();
3249
+ }
3250
+ }
3251
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3252
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3253
+ const labelBackups = this._captureMaskLabelBackups(masks);
3254
+ const activeObjectBackup = this._captureActiveObjectBackup();
2936
3255
  try {
2937
- masks2.forEach((mask) => {
2938
- mask.set({ visible: false });
2939
- });
3256
+ masks.forEach((mask) => this._removeLabelForMask(mask));
2940
3257
  this.canvas.discardActiveObject();
2941
3258
  this.canvas.renderAll();
3259
+ masks.forEach((mask) => {
3260
+ mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
3261
+ mask.setCoords();
3262
+ });
3263
+ this.canvas.renderAll();
2942
3264
  this.originalImage.setCoords();
2943
3265
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2944
3266
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
@@ -2950,47 +3272,13 @@ var ImageEditor = class {
2950
3272
  sealPartialEdges: this._getPartialExportEdges(imageBounds)
2951
3273
  });
2952
3274
  } finally {
2953
- maskVisibilityBackups.forEach((backup) => {
2954
- try {
2955
- backup.object.set({ visible: backup.visible });
2956
- } catch (error) {
2957
- void error;
2958
- }
2959
- });
2960
- this._restoreMaskExportBackups(maskStyleBackups2);
2961
- this._restoreMaskLabelBackups(labelBackups2);
2962
- this._restoreActiveObjectBackup(activeObjectBackup2);
3275
+ this._restoreMaskExportBackups(maskStyleBackups);
3276
+ this._restoreMaskLabelBackups(labelBackups);
3277
+ this._restoreActiveObjectBackup(activeObjectBackup);
2963
3278
  this.canvas.renderAll();
2964
3279
  }
2965
- }
2966
- const masks = this.canvas.getObjects().filter((object) => object.maskId);
2967
- const maskStyleBackups = this._captureMaskExportBackups(masks);
2968
- const labelBackups = this._captureMaskLabelBackups(masks);
2969
- const activeObjectBackup = this._captureActiveObjectBackup();
2970
- try {
2971
- masks.forEach((mask) => this._removeLabelForMask(mask));
2972
- this.canvas.discardActiveObject();
2973
- this.canvas.renderAll();
2974
- masks.forEach((mask) => {
2975
- mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
2976
- mask.setCoords();
2977
- });
2978
- this.canvas.renderAll();
2979
- this.originalImage.setCoords();
2980
- const imageBounds = this.originalImage.getBoundingRect(true, true);
2981
- const exportRegion = this._getClampedCanvasRegion(imageBounds);
2982
- return await this._exportCanvasRegionToDataURL({
2983
- ...exportRegion,
2984
- multiplier,
2985
- quality,
2986
- format,
2987
- sealPartialEdges: this._getPartialExportEdges(imageBounds)
2988
- });
2989
3280
  } finally {
2990
- this._restoreMaskExportBackups(maskStyleBackups);
2991
- this._restoreMaskLabelBackups(labelBackups);
2992
- this._restoreActiveObjectBackup(activeObjectBackup);
2993
- this.canvas.renderAll();
3281
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
2994
3282
  }
2995
3283
  }
2996
3284
  /**
@@ -3023,7 +3311,10 @@ var ImageEditor = class {
3023
3311
  */
3024
3312
  async exportImageFile(options = {}) {
3025
3313
  if (!this.originalImage) throw new Error("No image loaded");
3026
- this._assertIdleForOperation("exportImageFile");
3314
+ options = options || {};
3315
+ this._assertIdleForOperation("exportImageFile", options);
3316
+ const isNestedOperation = this._isOwnInternalOperation(options);
3317
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageFile");
3027
3318
  const {
3028
3319
  mergeMask = true,
3029
3320
  fileType = "jpeg",
@@ -3033,48 +3324,52 @@ var ImageEditor = class {
3033
3324
  } = options;
3034
3325
  const safeFileType = this._normalizeImageFormat(fileType);
3035
3326
  const normalizedQuality = this._normalizeQuality(quality);
3036
- let imageBase64;
3037
- if (mergeMask) {
3038
- imageBase64 = await this.exportImageBase64({
3039
- exportImageArea: true,
3040
- multiplier,
3041
- quality: normalizedQuality,
3042
- fileType: safeFileType
3043
- });
3044
- } else {
3045
- imageBase64 = await this.exportImageBase64({
3046
- exportImageArea: false,
3047
- multiplier,
3048
- quality: normalizedQuality,
3049
- fileType: safeFileType
3050
- });
3051
- }
3052
- let imageDataUrl = imageBase64;
3053
- if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3054
- imageDataUrl = await new Promise((resolve, reject) => {
3055
- const imageElement = new window.Image();
3056
- imageElement.crossOrigin = "Anonymous";
3057
- imageElement.onload = () => {
3058
- try {
3059
- const offscreenCanvas = document.createElement("canvas");
3060
- offscreenCanvas.width = imageElement.width;
3061
- offscreenCanvas.height = imageElement.height;
3062
- const context = offscreenCanvas.getContext("2d");
3063
- if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
3064
- context.drawImage(imageElement, 0, 0);
3065
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3066
- resolve(convertedDataUrl);
3067
- } catch (error) {
3068
- reject(error);
3069
- }
3070
- };
3071
- imageElement.onerror = reject;
3072
- imageElement.src = imageBase64;
3073
- });
3327
+ try {
3328
+ let imageBase64;
3329
+ if (mergeMask) {
3330
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3331
+ exportImageArea: true,
3332
+ multiplier,
3333
+ quality: normalizedQuality,
3334
+ fileType: safeFileType
3335
+ }));
3336
+ } else {
3337
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3338
+ exportImageArea: false,
3339
+ multiplier,
3340
+ quality: normalizedQuality,
3341
+ fileType: safeFileType
3342
+ }));
3343
+ }
3344
+ let imageDataUrl = imageBase64;
3345
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3346
+ imageDataUrl = await new Promise((resolve, reject) => {
3347
+ const imageElement = new window.Image();
3348
+ imageElement.crossOrigin = "Anonymous";
3349
+ imageElement.onload = () => {
3350
+ try {
3351
+ const offscreenCanvas = document.createElement("canvas");
3352
+ offscreenCanvas.width = imageElement.width;
3353
+ offscreenCanvas.height = imageElement.height;
3354
+ const context = offscreenCanvas.getContext("2d");
3355
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
3356
+ context.drawImage(imageElement, 0, 0);
3357
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3358
+ resolve(convertedDataUrl);
3359
+ } catch (error) {
3360
+ reject(error);
3361
+ }
3362
+ };
3363
+ imageElement.onerror = reject;
3364
+ imageElement.src = imageBase64;
3365
+ });
3366
+ }
3367
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3368
+ const mime = `image/${safeFileType}`;
3369
+ return new File([bytes], fileName, { type: mime });
3370
+ } finally {
3371
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3074
3372
  }
3075
- const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
3076
- const mime = `image/${safeFileType}`;
3077
- return new File([bytes], fileName, { type: mime });
3078
3373
  }
3079
3374
  _clearMaskPlacementMemory() {
3080
3375
  this._lastMask = null;
@@ -3082,7 +3377,7 @@ var ImageEditor = class {
3082
3377
  this._lastMaskInitialTop = null;
3083
3378
  this._lastMaskInitialWidth = null;
3084
3379
  }
3085
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
3380
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
3086
3381
  this._reportError(message, error);
3087
3382
  if (this._cropRect && this.canvas) this._removeCropRect();
3088
3383
  this._cropRect = null;
@@ -3093,7 +3388,7 @@ var ImageEditor = class {
3093
3388
  this._prevSelectionSetting = void 0;
3094
3389
  if (beforeJson) {
3095
3390
  try {
3096
- await this.loadFromState(beforeJson);
3391
+ await this.loadFromState(beforeJson, options);
3097
3392
  } catch (restoreError) {
3098
3393
  this._reportError("applyCrop: rollback failed", restoreError);
3099
3394
  }
@@ -3139,6 +3434,17 @@ var ImageEditor = class {
3139
3434
  this._cropRect = null;
3140
3435
  this._cropHandlers = [];
3141
3436
  }
3437
+ _getCropRectContentBounds(cropRect) {
3438
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
3439
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
3440
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
3441
+ return {
3442
+ left: Number(cropRect.left) || 0,
3443
+ top: Number(cropRect.top) || 0,
3444
+ width,
3445
+ height
3446
+ };
3447
+ }
3142
3448
  /**
3143
3449
  * Enters crop mode by creating a resizable crop rectangle above the base image.
3144
3450
  *
@@ -3162,14 +3468,19 @@ var ImageEditor = class {
3162
3468
  const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
3163
3469
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
3164
3470
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
3165
- const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
3166
- const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
3471
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
3472
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
3167
3473
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3168
3474
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3169
3475
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3170
3476
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3171
3477
  const width = minCropWidth;
3172
3478
  const height = minCropHeight;
3479
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
3480
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
3481
+ this._cropRotationWarningEmitted = true;
3482
+ this._reportWarning("crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported");
3483
+ }
3173
3484
  const cropRect = new fabric.Rect({
3174
3485
  left,
3175
3486
  top,
@@ -3181,8 +3492,8 @@ var ImageEditor = class {
3181
3492
  strokeWidth: 1,
3182
3493
  strokeUniform: true,
3183
3494
  selectable: true,
3184
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3185
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
3495
+ hasRotatingPoint: false,
3496
+ lockRotation: true,
3186
3497
  cornerSize: 8,
3187
3498
  objectCaching: false,
3188
3499
  originX: "left",
@@ -3219,7 +3530,7 @@ var ImageEditor = class {
3219
3530
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3220
3531
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3221
3532
  cropRect.setCoords();
3222
- const cropBounds = cropRect.getBoundingRect(true, true);
3533
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3223
3534
  const imageLeft = Number(imageBounds.left) || 0;
3224
3535
  const imageTop = Number(imageBounds.top) || 0;
3225
3536
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3293,94 +3604,100 @@ var ImageEditor = class {
3293
3604
  async applyCrop() {
3294
3605
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3295
3606
  this._assertIdleForOperation("applyCrop");
3296
- this._cropRect.setCoords();
3297
- const rectBounds = this._cropRect.getBoundingRect(true, true);
3298
- const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3299
- const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
3300
- this._restoreCropObjectState();
3301
- let beforeJson;
3302
- try {
3303
- beforeJson = this._serializeCanvasState();
3304
- } catch (error) {
3305
- this._reportError("applyCrop: failed to capture rollback state", error);
3306
- beforeJson = null;
3307
- }
3308
- if (!beforeJson) {
3309
- this.cancelCrop();
3310
- return;
3311
- }
3312
- const preservedMasks = [];
3607
+ const operationToken = this._beginBusyOperation("applyCrop");
3608
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3313
3609
  try {
3314
- const masks = this.canvas.getObjects().filter((object) => object.maskId);
3315
- if (masks && masks.length) {
3316
- masks.forEach((mask) => {
3317
- mask.setCoords();
3318
- const maskBounds = mask.getBoundingRect(true, true);
3319
- const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3320
- this._removeLabelForMask(mask);
3321
- this._cleanupMaskEvents(mask);
3322
- this.canvas.remove(mask);
3323
- if (shouldPreserveMasks && intersectsCrop) {
3324
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3325
- mask.set({ visible: true });
3326
- preservedMasks.push(mask);
3327
- }
3328
- });
3329
- this._clearMaskPlacementMemory();
3330
- this.canvas.discardActiveObject();
3331
- this.canvas.renderAll();
3610
+ this._cropRect.setCoords();
3611
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3612
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3613
+ const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
3614
+ this._restoreCropObjectState();
3615
+ let beforeJson;
3616
+ try {
3617
+ beforeJson = this._serializeCanvasState();
3618
+ } catch (error) {
3619
+ this._reportError("applyCrop: failed to capture rollback state", error);
3620
+ beforeJson = null;
3332
3621
  }
3333
- } catch (error) {
3334
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error);
3335
- return;
3336
- }
3337
- this._removeCropRect();
3338
- this._cropMode = false;
3339
- this.canvas.selection = !!this._prevSelectionSetting;
3340
- this._prevSelectionSetting = void 0;
3341
- let croppedBase64;
3342
- try {
3343
- croppedBase64 = await this._exportCanvasRegionToDataURL({
3344
- ...cropRegion,
3345
- multiplier: 1,
3346
- quality: this._normalizeQuality(this.options.downsampleQuality),
3347
- format: "jpeg"
3348
- });
3349
- } catch (error) {
3350
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error);
3351
- return;
3352
- }
3353
- try {
3354
- await this.loadImage(croppedBase64, { resetMaskCounter: false });
3355
- if (preservedMasks.length) {
3356
- preservedMasks.forEach((mask) => {
3357
- this._rebindMaskEvents(mask);
3358
- this.canvas.add(mask);
3359
- this.canvas.bringToFront(mask);
3622
+ if (!beforeJson) {
3623
+ this.cancelCrop();
3624
+ return;
3625
+ }
3626
+ const preservedMasks = [];
3627
+ try {
3628
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3629
+ if (masks && masks.length) {
3630
+ masks.forEach((mask) => {
3631
+ mask.setCoords();
3632
+ const maskBounds = mask.getBoundingRect(true, true);
3633
+ const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3634
+ this._removeLabelForMask(mask);
3635
+ this._cleanupMaskEvents(mask);
3636
+ this.canvas.remove(mask);
3637
+ if (shouldPreserveMasks && intersectsCrop) {
3638
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3639
+ mask.set({ visible: true });
3640
+ preservedMasks.push(mask);
3641
+ }
3642
+ });
3643
+ this._clearMaskPlacementMemory();
3644
+ this.canvas.discardActiveObject();
3645
+ this.canvas.renderAll();
3646
+ }
3647
+ } catch (error) {
3648
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error, internalOptions);
3649
+ return;
3650
+ }
3651
+ this._removeCropRect();
3652
+ this._cropMode = false;
3653
+ this.canvas.selection = !!this._prevSelectionSetting;
3654
+ this._prevSelectionSetting = void 0;
3655
+ let croppedBase64;
3656
+ try {
3657
+ croppedBase64 = await this._exportCanvasRegionToDataURL({
3658
+ ...cropRegion,
3659
+ multiplier: 1,
3660
+ quality: this._normalizeQuality(this.options.downsampleQuality),
3661
+ format: "jpeg"
3360
3662
  });
3361
- this._lastMask = preservedMasks[preservedMasks.length - 1];
3362
- this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
3363
- this._updateMaskList();
3364
- this.canvas.renderAll();
3663
+ } catch (error) {
3664
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error, internalOptions);
3665
+ return;
3365
3666
  }
3366
- } catch (error) {
3367
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
3368
- return;
3369
- }
3370
- let afterJson;
3371
- try {
3372
- afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
3373
- } catch (error) {
3374
- this._reportWarning("applyCrop: failed to serialize after state", error);
3375
- afterJson = null;
3376
- }
3377
- try {
3378
- this._pushStateTransition(beforeJson, afterJson);
3379
- } catch (error) {
3380
- this._reportWarning("applyCrop: failed to push history command", error);
3667
+ try {
3668
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3669
+ if (preservedMasks.length) {
3670
+ preservedMasks.forEach((mask) => {
3671
+ this._rebindMaskEvents(mask);
3672
+ this.canvas.add(mask);
3673
+ this.canvas.bringToFront(mask);
3674
+ });
3675
+ this._lastMask = preservedMasks[preservedMasks.length - 1];
3676
+ this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
3677
+ this._updateMaskList();
3678
+ this.canvas.renderAll();
3679
+ }
3680
+ } catch (error) {
3681
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error, internalOptions);
3682
+ return;
3683
+ }
3684
+ let afterJson;
3685
+ try {
3686
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
3687
+ } catch (error) {
3688
+ this._reportWarning("applyCrop: failed to serialize after state", error);
3689
+ afterJson = null;
3690
+ }
3691
+ try {
3692
+ this._pushStateTransition(beforeJson, afterJson);
3693
+ } catch (error) {
3694
+ this._reportWarning("applyCrop: failed to push history command", error);
3695
+ }
3696
+ this._updateUI();
3697
+ this.canvas.renderAll();
3698
+ } finally {
3699
+ this._endBusyOperation(operationToken);
3381
3700
  }
3382
- this._updateUI();
3383
- this.canvas.renderAll();
3384
3701
  }
3385
3702
  /* ---------- Misc / UI ---------- */
3386
3703
  /**
@@ -3800,11 +4117,11 @@ var HistoryManager = class {
3800
4117
  *
3801
4118
  * @returns {Promise<void>} Resolves after the undo task completes.
3802
4119
  */
3803
- undo() {
4120
+ undo(options = {}) {
3804
4121
  return this.enqueue(async () => {
3805
4122
  if (this.currentIndex >= 0) {
3806
4123
  const index = this.currentIndex;
3807
- await this.history[index].undo();
4124
+ await this.history[index].undo(options);
3808
4125
  this.currentIndex = index - 1;
3809
4126
  }
3810
4127
  });
@@ -3814,11 +4131,11 @@ var HistoryManager = class {
3814
4131
  *
3815
4132
  * @returns {Promise<void>} Resolves after the redo task completes.
3816
4133
  */
3817
- redo() {
4134
+ redo(options = {}) {
3818
4135
  return this.enqueue(async () => {
3819
4136
  if (this.currentIndex < this.history.length - 1) {
3820
4137
  const index = this.currentIndex + 1;
3821
- await this.history[index].execute();
4138
+ await this.history[index].execute(options);
3822
4139
  this.currentIndex = index;
3823
4140
  }
3824
4141
  });