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