@bensitu/image-editor 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2
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,8 @@ var ImageEditor = class {
77
77
  downsampleMimeType: null,
78
78
  imageLoadTimeoutMs: 3e4,
79
79
  exportMultiplier: 1,
80
+ maxExportPixels: 5e7,
81
+ maxHistorySize: 50,
80
82
  exportImageAreaByDefault: true,
81
83
  defaultMaskWidth: 50,
82
84
  defaultMaskHeight: 80,
@@ -105,6 +107,7 @@ var ImageEditor = class {
105
107
  ...userCrop
106
108
  }
107
109
  };
110
+ this._normalizeOptions();
108
111
  this._fabricLoaded = !!ensureFabric();
109
112
  if (!this._fabricLoaded) {
110
113
  this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
@@ -124,16 +127,18 @@ var ImageEditor = class {
124
127
  this._activeOperationToken = null;
125
128
  this.elements = {};
126
129
  this.isImageLoadedToCanvas = false;
127
- this.maxHistorySize = 50;
130
+ this.maxHistorySize = this.options.maxHistorySize;
128
131
  this._handlersByElementKey = {};
129
132
  this._elementCache = {};
130
133
  this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
134
+ this._elementOriginalDisabledState = /* @__PURE__ */ new Map();
131
135
  this._lastMask = null;
132
136
  this._lastMaskInitialLeft = null;
133
137
  this._lastMaskInitialTop = null;
134
138
  this._lastMaskInitialWidth = null;
135
139
  this._lastSnapshot = null;
136
140
  this._cropMode = false;
141
+ this._isApplyingCrop = false;
137
142
  this._cropRect = null;
138
143
  this._cropHandlers = [];
139
144
  this._cropPrevEvented = null;
@@ -146,6 +151,8 @@ var ImageEditor = class {
146
151
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
152
  this._disposed = false;
148
153
  this._initialized = false;
154
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
155
+ this._cropRotationWarningEmitted = false;
149
156
  this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
150
157
  this.animationQueue = new AnimationQueue();
151
158
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -210,7 +217,13 @@ var ImageEditor = class {
210
217
  * });
211
218
  */
212
219
  init(idMap = {}) {
213
- if (!this._fabricLoaded) return;
220
+ if (!this._fabricLoaded) {
221
+ this._fabricLoaded = !!ensureFabric();
222
+ if (!this._fabricLoaded) {
223
+ this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
224
+ return;
225
+ }
226
+ }
214
227
  if (this._initialized || this.canvas) this.dispose();
215
228
  this._disposed = false;
216
229
  this._initialized = true;
@@ -222,10 +235,11 @@ var ImageEditor = class {
222
235
  this._activeOperationName = null;
223
236
  this._activeOperationToken = null;
224
237
  this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
238
+ this._elementOriginalDisabledState = /* @__PURE__ */ new Map();
239
+ this._isApplyingCrop = false;
225
240
  this._containerOriginalOverflow = null;
226
241
  this._lastContainerViewportSize = null;
227
242
  this._canvasElementOriginalStyle = null;
228
- this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
229
243
  const defaults = {
230
244
  canvas: "fabricCanvas",
231
245
  canvasContainer: null,
@@ -264,6 +278,7 @@ var ImageEditor = class {
264
278
  redoButton: "redoButton",
265
279
  redoBtn: null,
266
280
  imageInput: "imageInput",
281
+ uploadArea: null,
267
282
  enterCropModeButton: "enterCropModeButton",
268
283
  cropBtn: null,
269
284
  applyCropButton: "applyCropButton",
@@ -279,7 +294,7 @@ var ImageEditor = class {
279
294
  this._updateMaskList();
280
295
  this._updateUI();
281
296
  if (this.options.initialImageBase64) {
282
- this.loadImage(this.options.initialImageBase64);
297
+ this.loadImage(this.options.initialImageBase64).catch((error) => this._reportError("initialImageBase64 could not be loaded", error));
283
298
  } else {
284
299
  this._updatePlaceholderStatus();
285
300
  }
@@ -341,6 +356,54 @@ var ImageEditor = class {
341
356
  `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
342
357
  );
343
358
  }
359
+ _normalizeFiniteNumber(value, fallback) {
360
+ const numericValue = Number(value);
361
+ return Number.isFinite(numericValue) ? numericValue : fallback;
362
+ }
363
+ _normalizePositiveNumber(value, fallback) {
364
+ const numericValue = this._normalizeFiniteNumber(value, fallback);
365
+ return numericValue > 0 ? numericValue : fallback;
366
+ }
367
+ _normalizeNonNegativeNumber(value, fallback) {
368
+ const numericValue = this._normalizeFiniteNumber(value, fallback);
369
+ return numericValue >= 0 ? numericValue : fallback;
370
+ }
371
+ _normalizePositiveInteger(value, fallback) {
372
+ const numericValue = this._normalizePositiveNumber(value, fallback);
373
+ return Math.max(1, Math.floor(numericValue));
374
+ }
375
+ _normalizeOptions() {
376
+ const options = this.options || {};
377
+ options.canvasWidth = this._normalizePositiveNumber(options.canvasWidth, 800);
378
+ options.canvasHeight = this._normalizePositiveNumber(options.canvasHeight, 600);
379
+ options.animationDuration = this._normalizeNonNegativeNumber(options.animationDuration, 300);
380
+ const minScale = this._normalizePositiveNumber(options.minScale, 0.1);
381
+ const maxScale = this._normalizePositiveNumber(options.maxScale, 5);
382
+ if (minScale > maxScale) {
383
+ options.minScale = 0.1;
384
+ options.maxScale = 5;
385
+ } else {
386
+ options.minScale = minScale;
387
+ options.maxScale = maxScale;
388
+ }
389
+ options.scaleStep = this._normalizePositiveNumber(options.scaleStep, 0.05);
390
+ options.rotationStep = this._normalizeFiniteNumber(options.rotationStep, 90);
391
+ options.downsampleMaxWidth = this._normalizePositiveNumber(options.downsampleMaxWidth, 4e3);
392
+ options.downsampleMaxHeight = this._normalizePositiveNumber(options.downsampleMaxHeight, 3e3);
393
+ options.downsampleQuality = options.downsampleQuality == null ? 0.92 : Math.max(0, Math.min(1, this._normalizeFiniteNumber(options.downsampleQuality, 0.92)));
394
+ options.imageLoadTimeoutMs = this._normalizePositiveNumber(options.imageLoadTimeoutMs, 3e4);
395
+ options.exportMultiplier = this._normalizePositiveNumber(options.exportMultiplier, 1);
396
+ options.maxExportPixels = this._normalizePositiveInteger(options.maxExportPixels, 5e7);
397
+ options.maxHistorySize = this._normalizePositiveInteger(options.maxHistorySize, 50);
398
+ options.defaultMaskWidth = this._normalizePositiveNumber(options.defaultMaskWidth, 50);
399
+ options.defaultMaskHeight = this._normalizePositiveNumber(options.defaultMaskHeight, 80);
400
+ options.maskLabelOffset = this._normalizeNonNegativeNumber(options.maskLabelOffset, 3);
401
+ if (options.crop) {
402
+ options.crop.minWidth = this._normalizePositiveNumber(options.crop.minWidth, 100);
403
+ options.crop.minHeight = this._normalizePositiveNumber(options.crop.minHeight, 100);
404
+ options.crop.padding = this._normalizeNonNegativeNumber(options.crop.padding, 10);
405
+ }
406
+ }
344
407
  _reportError(message, error = null) {
345
408
  const handler = this.options && this.options.onError;
346
409
  if (typeof handler !== "function") return;
@@ -357,10 +420,18 @@ var ImageEditor = class {
357
420
  } catch {
358
421
  }
359
422
  }
423
+ _emitSafeCallback(callback, message) {
424
+ if (typeof callback !== "function") return;
425
+ try {
426
+ callback();
427
+ } catch (error) {
428
+ this._reportWarning(message, error);
429
+ }
430
+ }
360
431
  _notifyImageLoaded() {
361
432
  const optionsCallback = this.options && this.options.onImageLoaded;
362
433
  const callback = typeof optionsCallback === "function" ? optionsCallback : this.onImageLoaded;
363
- if (typeof callback === "function") callback();
434
+ this._emitSafeCallback(callback, "onImageLoaded callback failed");
364
435
  }
365
436
  /**
366
437
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
@@ -480,13 +551,14 @@ var ImageEditor = class {
480
551
  if (!this.containerElement || !this.containerElement.style) return;
481
552
  this._captureContainerOverflowState();
482
553
  const shouldPreserveScroll = options.preserveScroll === true;
483
- if (this.options.coverImageToCanvas) {
554
+ const layoutMode = this._getImageLayoutMode();
555
+ if (layoutMode === "cover") {
484
556
  this.containerElement.style.overflow = "scroll";
485
557
  if (!shouldPreserveScroll) {
486
558
  this.containerElement.scrollLeft = 0;
487
559
  this.containerElement.scrollTop = 0;
488
560
  }
489
- } else if (this.options.fitImageToCanvas) {
561
+ } else if (layoutMode === "fit") {
490
562
  this.containerElement.style.overflow = "auto";
491
563
  if (!shouldPreserveScroll) {
492
564
  this.containerElement.scrollLeft = 0;
@@ -598,7 +670,6 @@ var ImageEditor = class {
598
670
  _loadImageFile(file) {
599
671
  if (!this._isSupportedImageFile(file)) {
600
672
  const error = new Error("Selected file is not a supported image");
601
- this._reportError("Selected file is not a supported image", error);
602
673
  return Promise.reject(error);
603
674
  }
604
675
  return new Promise((resolve, reject) => {
@@ -637,6 +708,12 @@ var ImageEditor = class {
637
708
  `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
638
709
  );
639
710
  }
711
+ _getImageLayoutMode() {
712
+ if (this.options.fitImageToCanvas) return "fit";
713
+ if (this.options.coverImageToCanvas) return "cover";
714
+ if (this.options.expandCanvasToImage) return "expand";
715
+ return "contain";
716
+ }
640
717
  /**
641
718
  * Loads a base64 data URL into the Fabric canvas as the base image.
642
719
  *
@@ -650,12 +727,17 @@ var ImageEditor = class {
650
727
  if (!this._fabricLoaded) return;
651
728
  if (!this.canvas || this._disposed) return;
652
729
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
730
+ options = options || {};
653
731
  this._assertIdleForOperation("loadImage", options);
654
- this._isLoading = true;
655
- this._updateUI();
656
- this._warnOnImageLayoutOptionConflict();
657
- const transaction = this._captureLoadImageTransaction();
732
+ const isNestedOperation = this._isOwnInternalOperation(options);
733
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("loadImage");
734
+ let transaction = null;
735
+ let shouldNotifyImageLoaded;
658
736
  try {
737
+ this._isLoading = true;
738
+ this._updateUI();
739
+ this._warnOnImageLayoutOptionConflict();
740
+ transaction = this._captureLoadImageTransaction();
659
741
  const imageElement = await this._createImageElement(imageBase64);
660
742
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
661
743
  let loadSource = imageBase64;
@@ -695,7 +777,8 @@ var ImageEditor = class {
695
777
  const viewport = this._getContainerViewportSize();
696
778
  const minWidth = viewport.width;
697
779
  const minHeight = viewport.height;
698
- if (this.options.fitImageToCanvas) {
780
+ const layoutMode = this._getImageLayoutMode();
781
+ if (layoutMode === "fit") {
699
782
  const canvasWidth = Math.max(1, minWidth - 1);
700
783
  const canvasHeight = Math.max(1, minHeight - 1);
701
784
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -703,13 +786,13 @@ var ImageEditor = class {
703
786
  fabricImage.set({ left: 0, top: 0 });
704
787
  fabricImage.scale(fitScale);
705
788
  this.baseImageScale = fabricImage.scaleX || 1;
706
- } else if (this.options.coverImageToCanvas) {
789
+ } else if (layoutMode === "cover") {
707
790
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
708
791
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
709
792
  fabricImage.set({ left: 0, top: 0 });
710
793
  fabricImage.scale(layout.scale);
711
794
  this.baseImageScale = fabricImage.scaleX || 1;
712
- } else if (this.options.expandCanvasToImage) {
795
+ } else if (layoutMode === "expand") {
713
796
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
714
797
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
715
798
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -738,14 +821,21 @@ var ImageEditor = class {
738
821
  this._updateUI();
739
822
  this.canvas.renderAll();
740
823
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
741
- this._notifyImageLoaded();
824
+ shouldNotifyImageLoaded = true;
742
825
  } catch (error) {
743
- await this._rollbackLoadImageTransaction(transaction);
826
+ await this._rollbackLoadImageTransaction(
827
+ transaction,
828
+ this._withInternalOperationOptions(operationToken)
829
+ );
744
830
  throw error;
745
831
  } finally {
746
832
  this._isLoading = false;
833
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
747
834
  if (!this._disposed && this.canvas) this._updateUI();
748
835
  }
836
+ if (shouldNotifyImageLoaded && !this._disposed && this.canvas) {
837
+ this._notifyImageLoaded();
838
+ }
749
839
  }
750
840
  /**
751
841
  * Checks whether there is a loaded image on the current canvas.
@@ -762,7 +852,7 @@ var ImageEditor = class {
762
852
  * @public
763
853
  */
764
854
  isBusy() {
765
- return !!(this.isAnimating || this._cropMode || this._isLoading || this._activeOperationToken || this.animationQueue && this.animationQueue.isBusy());
855
+ return !!(this.isAnimating || this._cropMode || this._isApplyingCrop || this._isLoading || this._activeOperationToken || this.animationQueue && this.animationQueue.isBusy());
766
856
  }
767
857
  /**
768
858
  * Creates an HTMLImageElement from a given data URL.
@@ -856,13 +946,13 @@ var ImageEditor = class {
856
946
  canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
857
947
  };
858
948
  }
859
- async _rollbackLoadImageTransaction(transaction) {
949
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
860
950
  if (!transaction || !this.canvas || this._disposed) return;
861
951
  let didRestoreCanvasState = false;
862
952
  let didFailCanvasRestore = false;
863
953
  try {
864
954
  if (transaction.canvasState) {
865
- await this.loadFromState(transaction.canvasState);
955
+ await this.loadFromState(transaction.canvasState, options);
866
956
  didRestoreCanvasState = true;
867
957
  }
868
958
  } catch (error) {
@@ -1112,9 +1202,9 @@ var ImageEditor = class {
1112
1202
  }
1113
1203
  _getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
1114
1204
  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);
1205
+ const safetyMargin2 = this._getScrollSafetyMargin();
1206
+ const safeWidth = Math.max(1, viewport.width - safetyMargin2);
1207
+ const safeHeight = Math.max(1, viewport.height - safetyMargin2);
1118
1208
  return {
1119
1209
  width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
1120
1210
  height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
@@ -1140,9 +1230,17 @@ var ImageEditor = class {
1140
1230
  }
1141
1231
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1142
1232
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1233
+ const safetyMargin = this._getScrollSafetyMargin();
1234
+ const layoutMode = this._getImageLayoutMode();
1235
+ const shouldReserveNoScrollbarMargin = layoutMode === "fit" || layoutMode === "cover";
1236
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1237
+ const margin = hasOppositeScrollbar ? safetyMargin : shouldReserveNoScrollbarMargin ? 1 : 0;
1238
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1239
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1240
+ };
1143
1241
  return {
1144
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1145
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1242
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1243
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1146
1244
  viewportWidth: effectiveWidth,
1147
1245
  viewportHeight: effectiveHeight,
1148
1246
  hasHorizontal,
@@ -1264,6 +1362,45 @@ var ImageEditor = class {
1264
1362
  });
1265
1363
  }
1266
1364
  }
1365
+ _getSerializableStateObjects() {
1366
+ if (!this.canvas) return [];
1367
+ return this.canvas.getObjects().filter((object) => !object.isCropRect && !object.maskLabel);
1368
+ }
1369
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1370
+ if (!Array.isArray(serializedObjects)) return;
1371
+ const fabricObjects = this._getSerializableStateObjects();
1372
+ const numericProperties = [
1373
+ "left",
1374
+ "top",
1375
+ "width",
1376
+ "height",
1377
+ "scaleX",
1378
+ "scaleY",
1379
+ "angle",
1380
+ "skewX",
1381
+ "skewY",
1382
+ "cropX",
1383
+ "cropY",
1384
+ "radius",
1385
+ "rx",
1386
+ "ry",
1387
+ "strokeWidth"
1388
+ ];
1389
+ serializedObjects.forEach((serializedObject, index) => {
1390
+ const fabricObject = fabricObjects[index];
1391
+ if (!serializedObject || !fabricObject) return;
1392
+ numericProperties.forEach((property) => {
1393
+ const numericValue = Number(fabricObject[property]);
1394
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1395
+ });
1396
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1397
+ serializedObject.points = fabricObject.points.map((point) => ({
1398
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1399
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1400
+ }));
1401
+ }
1402
+ });
1403
+ }
1267
1404
  _restoreMaskControls(mask) {
1268
1405
  if (!mask) return;
1269
1406
  const cornerSize = Number(mask.cornerSize);
@@ -1309,6 +1446,7 @@ var ImageEditor = class {
1309
1446
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1310
1447
  if (Array.isArray(jsonObject.objects)) {
1311
1448
  jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
1449
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1312
1450
  }
1313
1451
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1314
1452
  return JSON.stringify(jsonObject);
@@ -1383,6 +1521,12 @@ var ImageEditor = class {
1383
1521
  if (!Number.isFinite(numericValue)) return false;
1384
1522
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1385
1523
  }
1524
+ _hasScaledImageEdge(axis) {
1525
+ if (!this.originalImage) return false;
1526
+ const scale = Number(axis === "y" ? this.originalImage.scaleY : this.originalImage.scaleX);
1527
+ if (!Number.isFinite(scale)) return false;
1528
+ return Math.abs(scale - 1) > 0.01;
1529
+ }
1386
1530
  _getPartialExportEdges(bounds) {
1387
1531
  if (!bounds) return null;
1388
1532
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1391,8 +1535,8 @@ var ImageEditor = class {
1391
1535
  return {
1392
1536
  left: this._hasFractionalCanvasEdge(bounds.left),
1393
1537
  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))
1538
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge("x"),
1539
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge("y")
1396
1540
  };
1397
1541
  }
1398
1542
  async _sealPartialTransparentEdges(dataUrl, edges) {
@@ -1452,7 +1596,8 @@ var ImageEditor = class {
1452
1596
  * @private
1453
1597
  */
1454
1598
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1455
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1599
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1600
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1456
1601
  const safeFormat = this._normalizeImageFormat(format);
1457
1602
  const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1458
1603
  let regionDataUrl = this.canvas.toDataURL({
@@ -1468,6 +1613,25 @@ var ImageEditor = class {
1468
1613
  if (safeFormat !== "jpeg") return regionDataUrl;
1469
1614
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1470
1615
  }
1616
+ _getSafeExportMultiplier(multiplier) {
1617
+ const numericMultiplier = Number(multiplier);
1618
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1619
+ throw new Error("Export multiplier must be a finite positive number");
1620
+ }
1621
+ return Math.max(1, numericMultiplier);
1622
+ }
1623
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1624
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1625
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1626
+ const outputWidth = Math.ceil(width * safeMultiplier);
1627
+ const outputHeight = Math.ceil(height * safeMultiplier);
1628
+ const outputPixels = outputWidth * outputHeight;
1629
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1630
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0 ? Math.floor(configuredMaxPixels) : 5e7;
1631
+ if (outputPixels > maxPixels) {
1632
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1633
+ }
1634
+ }
1471
1635
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1472
1636
  const imageElement = await this._createImageElement(dataUrl);
1473
1637
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1485,7 +1649,23 @@ var ImageEditor = class {
1485
1649
  _getJpegBackgroundColor() {
1486
1650
  const backgroundColor = String(this.options.backgroundColor || "").trim();
1487
1651
  if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return "#ffffff";
1488
- return backgroundColor;
1652
+ return this._isValidCanvasFillStyle(backgroundColor) ? backgroundColor : "#ffffff";
1653
+ }
1654
+ _isValidCanvasFillStyle(color) {
1655
+ try {
1656
+ if (typeof document === "undefined" || !document.createElement) return false;
1657
+ const validationCanvas = document.createElement("canvas");
1658
+ const context = validationCanvas.getContext && validationCanvas.getContext("2d");
1659
+ if (!context) return false;
1660
+ context.fillStyle = "#010203";
1661
+ context.fillStyle = color;
1662
+ if (context.fillStyle !== "#010203") return true;
1663
+ context.fillStyle = "#040506";
1664
+ context.fillStyle = color;
1665
+ return context.fillStyle !== "#040506";
1666
+ } catch {
1667
+ return false;
1668
+ }
1489
1669
  }
1490
1670
  _isTransparentCssColor(color) {
1491
1671
  const normalizedColor = String(color || "").trim().toLowerCase();
@@ -1512,6 +1692,7 @@ var ImageEditor = class {
1512
1692
  }
1513
1693
  _decodeBase64Payload(base64Payload) {
1514
1694
  const payload = String(base64Payload || "");
1695
+ if (!payload) throw new Error("Data URL base64 payload is empty");
1515
1696
  if (typeof atob === "function") {
1516
1697
  return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
1517
1698
  }
@@ -1520,6 +1701,13 @@ var ImageEditor = class {
1520
1701
  }
1521
1702
  throw new Error("Base64 decoding is unavailable");
1522
1703
  }
1704
+ _decodeDataUrlPayload(dataUrl) {
1705
+ const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
1706
+ if (!match || !match[2]) {
1707
+ throw new Error("Export produced an invalid or empty base64 data URL");
1708
+ }
1709
+ return this._decodeBase64Payload(match[2]);
1710
+ }
1523
1711
  /**
1524
1712
  * Gets the top-left corner coordinates of the given object.
1525
1713
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1629,13 +1817,42 @@ var ImageEditor = class {
1629
1817
  const currentHeight = this.canvas.getHeight();
1630
1818
  let requiredWidth = currentWidth;
1631
1819
  let requiredHeight = currentHeight;
1632
- fabricObjects.forEach((fabricObject) => {
1820
+ const layoutMode = this._getImageLayoutMode();
1821
+ const usesScrollableFitBounds = layoutMode === "fit" || layoutMode === "cover";
1822
+ let contentWidth = 0;
1823
+ let contentHeight = 0;
1824
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1633
1825
  if (!fabricObject) return;
1634
1826
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1635
1827
  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));
1828
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
1829
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
1830
+ contentWidth = Math.max(contentWidth, right);
1831
+ contentHeight = Math.max(contentHeight, bottom);
1832
+ return { right, bottom };
1833
+ };
1834
+ fabricObjects.forEach((fabricObject) => {
1835
+ const bounds = includeObjectBounds(fabricObject, padding);
1836
+ if (!bounds) return;
1837
+ requiredWidth = Math.max(requiredWidth, bounds.right);
1838
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1638
1839
  });
1840
+ if (usesScrollableFitBounds) {
1841
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
1842
+ this.canvas.getObjects().forEach((object) => {
1843
+ if (object && object.maskId) includeObjectBounds(object, padding);
1844
+ });
1845
+ const contentSize = this._getScrollableCanvasSize(
1846
+ Math.max(1, contentWidth),
1847
+ Math.max(1, contentHeight)
1848
+ );
1849
+ const newWidth2 = contentSize.hasHorizontal ? Math.max(currentWidth, contentSize.width) : contentSize.width;
1850
+ const newHeight2 = contentSize.hasVertical ? Math.max(currentHeight, contentSize.height) : contentSize.height;
1851
+ if (newWidth2 !== currentWidth || newHeight2 !== currentHeight) {
1852
+ this._setCanvasSizeInt(newWidth2, newHeight2);
1853
+ }
1854
+ return;
1855
+ }
1639
1856
  let minWidth = 0;
1640
1857
  let minHeight = 0;
1641
1858
  if (this.containerElement) {
@@ -1653,16 +1870,60 @@ var ImageEditor = class {
1653
1870
  this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
1654
1871
  }
1655
1872
  }
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);
1873
+ _captureImageDisplayBounds() {
1874
+ if (!this.originalImage || !this.canvas) return null;
1875
+ this.originalImage.setCoords();
1876
+ const bounds = this.originalImage.getBoundingRect(true, true);
1877
+ const width = Number(bounds && bounds.width);
1878
+ const height = Number(bounds && bounds.height);
1879
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
1880
+ return {
1881
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
1882
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
1883
+ width,
1884
+ height
1885
+ };
1886
+ }
1887
+ _restoreImageDisplayBounds(displayBounds) {
1888
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
1889
+ const imageWidth = Number(this.originalImage.width);
1890
+ const imageHeight = Number(this.originalImage.height);
1891
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
1892
+ const scaleX = Number(displayBounds.width) / imageWidth;
1893
+ const scaleY = Number(displayBounds.height) / imageHeight;
1894
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
1895
+ const left = Number(displayBounds.left) || 0;
1896
+ const top = Number(displayBounds.top) || 0;
1897
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
1898
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
1899
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
1900
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
1901
+ const layoutMode = this._getImageLayoutMode();
1902
+ if (layoutMode === "fit" || layoutMode === "cover") {
1903
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
1904
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
1905
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
1906
+ }
1907
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
1908
+ this._setCanvasSizeInt(
1909
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
1910
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
1911
+ );
1912
+ }
1913
+ this.originalImage.set({
1914
+ originX: "left",
1915
+ originY: "top",
1916
+ left,
1917
+ top,
1918
+ scaleX,
1919
+ scaleY
1920
+ });
1921
+ this.originalImage.setCoords();
1922
+ this.baseImageScale = scaleX;
1923
+ this.currentScale = 1;
1924
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1925
+ this._updateInputs();
1926
+ this.canvas.renderAll();
1666
1927
  }
1667
1928
  /**
1668
1929
  * Scales the original image by a given factor, with animation.
@@ -1677,7 +1938,14 @@ var ImageEditor = class {
1677
1938
  } catch (error) {
1678
1939
  return Promise.reject(error);
1679
1940
  }
1680
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1941
+ return this.animationQueue.add(async () => {
1942
+ const operationToken = this._beginBusyOperation("scaleImage");
1943
+ try {
1944
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
1945
+ } finally {
1946
+ this._endBusyOperation(operationToken);
1947
+ }
1948
+ }).finally(() => {
1681
1949
  if (!this._disposed && this.canvas) this._updateUI();
1682
1950
  });
1683
1951
  }
@@ -1720,7 +1988,7 @@ var ImageEditor = class {
1720
1988
  if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1721
1989
  throw new Error(`${operationName} cannot run while crop mode is active`);
1722
1990
  }
1723
- if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1991
+ if ((this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) && !isOwnInternalOperation) {
1724
1992
  throw new Error(`${operationName} cannot run while an animation is running`);
1725
1993
  }
1726
1994
  if (this._isLoading && !isOwnInternalOperation) {
@@ -1807,10 +2075,12 @@ var ImageEditor = class {
1807
2075
  async _scaleImageImpl(factor, options = {}) {
1808
2076
  if (!this.originalImage || this._disposed) return;
1809
2077
  if (this.isAnimating) return;
2078
+ const numericFactor = Number(factor);
2079
+ if (!Number.isFinite(numericFactor)) return;
1810
2080
  const saveHistory = options.saveHistory !== false;
1811
2081
  let didStartAnimation = false;
1812
2082
  try {
1813
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
2083
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, numericFactor));
1814
2084
  this.currentScale = factor;
1815
2085
  this.isAnimating = true;
1816
2086
  didStartAnimation = true;
@@ -1833,7 +2103,7 @@ var ImageEditor = class {
1833
2103
  if (object.maskId) this._syncMaskLabel(object);
1834
2104
  });
1835
2105
  this._updateInputs();
1836
- if (saveHistory) this.saveState();
2106
+ if (saveHistory) this.saveState(options);
1837
2107
  } finally {
1838
2108
  if (didStartAnimation) {
1839
2109
  this.isAnimating = false;
@@ -1855,7 +2125,14 @@ var ImageEditor = class {
1855
2125
  } catch (error) {
1856
2126
  return Promise.reject(error);
1857
2127
  }
1858
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
2128
+ return this.animationQueue.add(async () => {
2129
+ const operationToken = this._beginBusyOperation("rotateImage");
2130
+ try {
2131
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2132
+ } finally {
2133
+ this._endBusyOperation(operationToken);
2134
+ }
2135
+ }).finally(() => {
1859
2136
  if (!this._disposed && this.canvas) this._updateUI();
1860
2137
  });
1861
2138
  }
@@ -1869,7 +2146,8 @@ var ImageEditor = class {
1869
2146
  async _rotateImageImpl(degrees, options = {}) {
1870
2147
  if (!this.originalImage || this._disposed) return;
1871
2148
  if (this.isAnimating) return;
1872
- if (isNaN(degrees)) return;
2149
+ const numericDegrees = Number(degrees);
2150
+ if (!Number.isFinite(numericDegrees)) return;
1873
2151
  const saveHistory = options.saveHistory !== false;
1874
2152
  const image = this.originalImage;
1875
2153
  const previousOriginX = image.originX || "left";
@@ -1878,6 +2156,7 @@ var ImageEditor = class {
1878
2156
  let didStartAnimation = false;
1879
2157
  let didCompleteRotation = false;
1880
2158
  try {
2159
+ degrees = numericDegrees;
1881
2160
  this.currentRotation = degrees;
1882
2161
  this.isAnimating = true;
1883
2162
  didStartAnimation = true;
@@ -1898,7 +2177,7 @@ var ImageEditor = class {
1898
2177
  if (object.maskId) this._syncMaskLabel(object);
1899
2178
  });
1900
2179
  this._updateInputs();
1901
- if (saveHistory) this.saveState();
2180
+ if (saveHistory) this.saveState(options);
1902
2181
  didCompleteRotation = true;
1903
2182
  } finally {
1904
2183
  if (!didCompleteRotation && !this._disposed && image) {
@@ -1925,19 +2204,22 @@ var ImageEditor = class {
1925
2204
  return Promise.reject(error);
1926
2205
  }
1927
2206
  return this.animationQueue.add(async () => {
2207
+ const operationToken = this._beginBusyOperation("resetImageTransform");
1928
2208
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1929
2209
  try {
1930
- await this._scaleImageImpl(1, { saveHistory: false });
1931
- await this._rotateImageImpl(0, { saveHistory: false });
2210
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2211
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
1932
2212
  const after = this._captureCanvasStateOrThrow("resetImageTransform");
1933
2213
  this._pushStateTransition(before, after);
1934
2214
  } catch (error) {
1935
2215
  try {
1936
- await this.loadFromState(before);
2216
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
1937
2217
  } catch (restoreError) {
1938
2218
  this._reportError("resetImageTransform rollback failed", restoreError);
1939
2219
  }
1940
2220
  throw error;
2221
+ } finally {
2222
+ this._endBusyOperation(operationToken);
1941
2223
  }
1942
2224
  }).finally(() => {
1943
2225
  if (!this._disposed && this.canvas) this._updateUI();
@@ -1962,8 +2244,13 @@ var ImageEditor = class {
1962
2244
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1963
2245
  * @public
1964
2246
  */
1965
- loadFromState(serializedState) {
2247
+ loadFromState(serializedState, options = {}) {
1966
2248
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2249
+ try {
2250
+ this._assertIdleForOperation("loadFromState", options);
2251
+ } catch (error) {
2252
+ return Promise.reject(error);
2253
+ }
1967
2254
  if (this._cropMode || this._cropRect) {
1968
2255
  this._removeCropRect();
1969
2256
  this._restoreCropObjectState();
@@ -2120,22 +2407,29 @@ var ImageEditor = class {
2120
2407
  * @returns {void}
2121
2408
  * @public
2122
2409
  */
2123
- saveState() {
2410
+ saveState(options = {}) {
2124
2411
  if (!this.canvas) return;
2412
+ try {
2413
+ this._assertIdleForOperation("saveState", options);
2414
+ } catch (error) {
2415
+ this._reportError("saveState blocked", error);
2416
+ this._updateUI();
2417
+ return;
2418
+ }
2125
2419
  try {
2126
2420
  const after = this._captureCanvasStateOrThrow("saveState");
2127
2421
  const before = this._lastSnapshot || after;
2128
2422
  if (after === before) return;
2129
2423
  let executedOnce = false;
2130
2424
  const command = new Command(
2131
- () => {
2425
+ (commandOptions = {}) => {
2132
2426
  if (executedOnce) {
2133
- return this.loadFromState(after);
2427
+ return this.loadFromState(after, commandOptions);
2134
2428
  }
2135
2429
  executedOnce = true;
2136
2430
  return void 0;
2137
2431
  },
2138
- () => this.loadFromState(before)
2432
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2139
2433
  );
2140
2434
  this.historyManager.execute(command);
2141
2435
  this._lastSnapshot = after;
@@ -2164,8 +2458,8 @@ var ImageEditor = class {
2164
2458
  if (before === after) return;
2165
2459
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2166
2460
  const command = new Command(
2167
- () => this.loadFromState(after),
2168
- () => this.loadFromState(before)
2461
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2462
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2169
2463
  );
2170
2464
  this.historyManager.push(command);
2171
2465
  this._lastSnapshot = after;
@@ -2178,8 +2472,16 @@ var ImageEditor = class {
2178
2472
  * @public
2179
2473
  */
2180
2474
  undo() {
2181
- return this.historyManager.undo().then(() => {
2475
+ try {
2476
+ this._assertIdleForOperation("undo");
2477
+ } catch (error) {
2478
+ return Promise.reject(error);
2479
+ }
2480
+ const operationToken = this._beginBusyOperation("undo");
2481
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken)).then(() => {
2182
2482
  this._updateUI();
2483
+ }).finally(() => {
2484
+ this._endBusyOperation(operationToken);
2183
2485
  }).catch((error) => {
2184
2486
  this._reportError("undo failed", error);
2185
2487
  throw error;
@@ -2192,8 +2494,16 @@ var ImageEditor = class {
2192
2494
  * @public
2193
2495
  */
2194
2496
  redo() {
2195
- return this.historyManager.redo().then(() => {
2497
+ try {
2498
+ this._assertIdleForOperation("redo");
2499
+ } catch (error) {
2500
+ return Promise.reject(error);
2501
+ }
2502
+ const operationToken = this._beginBusyOperation("redo");
2503
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken)).then(() => {
2196
2504
  this._updateUI();
2505
+ }).finally(() => {
2506
+ this._endBusyOperation(operationToken);
2197
2507
  }).catch((error) => {
2198
2508
  this._reportError("redo failed", error);
2199
2509
  throw error;
@@ -2309,30 +2619,64 @@ var ImageEditor = class {
2309
2619
  }
2310
2620
  return value != null ? value : fallback;
2311
2621
  };
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");
2622
+ const rejectInvalidMask = (message, error = null) => {
2623
+ this._reportWarning(`createMask: ${message}`, error);
2624
+ return null;
2625
+ };
2626
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
2627
+ const resolvedValue = resolveValue(value, fallback, axis);
2628
+ const numericValue = Number(resolvedValue);
2629
+ if (!Number.isFinite(numericValue)) {
2630
+ throw new Error(`${fieldName} must be a finite number`);
2631
+ }
2632
+ if (constraints.positive && numericValue <= 0) {
2633
+ throw new Error(`${fieldName} must be greater than 0`);
2634
+ }
2635
+ if (constraints.nonNegative && numericValue < 0) {
2636
+ throw new Error(`${fieldName} must be 0 or greater`);
2637
+ }
2638
+ return numericValue;
2639
+ };
2640
+ try {
2641
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, "width", "gap", { nonNegative: true });
2642
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, "width", "width", { positive: true });
2643
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, "height", "height", { positive: true });
2644
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, "width", "angle");
2645
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, "width", "alpha")));
2646
+ if (maskConfig.left === void 0 && this._lastMask) {
2647
+ const previousMask = this._lastMask;
2648
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2649
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2650
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2651
+ top = Math.round(previousBounds.top ?? firstOffset);
2652
+ } else {
2653
+ left = resolveNumber(maskConfig.left, firstOffset, "width", "left");
2654
+ top = resolveNumber(maskConfig.top, firstOffset, "height", "top");
2655
+ }
2656
+ } catch (error) {
2657
+ return rejectInvalidMask("invalid numeric configuration", error);
2321
2658
  }
2322
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2323
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
2324
2659
  maskConfig.left = left;
2325
2660
  maskConfig.top = top;
2326
2661
  let mask;
2327
2662
  if (typeof maskConfig.fabricGenerator === "function") {
2328
- mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
2663
+ try {
2664
+ mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
2665
+ } catch (error) {
2666
+ return rejectInvalidMask("fabricGenerator failed", error);
2667
+ }
2329
2668
  } else {
2330
2669
  switch (shapeType) {
2331
2670
  case "circle":
2671
+ try {
2672
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min", "radius", { positive: true });
2673
+ } catch (error) {
2674
+ return rejectInvalidMask("invalid circle radius", error);
2675
+ }
2332
2676
  mask = new fabric.Circle({
2333
2677
  left,
2334
2678
  top,
2335
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
2679
+ radius: maskConfig.radius,
2336
2680
  fill: maskConfig.color,
2337
2681
  opacity: maskConfig.alpha,
2338
2682
  angle: maskConfig.angle,
@@ -2340,11 +2684,17 @@ var ImageEditor = class {
2340
2684
  });
2341
2685
  break;
2342
2686
  case "ellipse":
2687
+ try {
2688
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, "width", "rx", { positive: true });
2689
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, "height", "ry", { positive: true });
2690
+ } catch (error) {
2691
+ return rejectInvalidMask("invalid ellipse radius", error);
2692
+ }
2343
2693
  mask = new fabric.Ellipse({
2344
2694
  left,
2345
2695
  top,
2346
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
2347
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
2696
+ rx: maskConfig.rx,
2697
+ ry: maskConfig.ry,
2348
2698
  fill: maskConfig.color,
2349
2699
  opacity: maskConfig.alpha,
2350
2700
  angle: maskConfig.angle,
@@ -2353,8 +2703,31 @@ var ImageEditor = class {
2353
2703
  break;
2354
2704
  case "polygon": {
2355
2705
  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) });
2706
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
2707
+ return rejectInvalidMask("polygon masks require at least three points");
2708
+ }
2709
+ try {
2710
+ polygonPoints = polygonPoints.map((point) => {
2711
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
2712
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
2713
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
2714
+ throw new Error("polygon point coordinates must be finite numbers");
2715
+ }
2716
+ return { x, y };
2717
+ });
2718
+ } catch (error) {
2719
+ return rejectInvalidMask("invalid polygon points", error);
2720
+ }
2721
+ const uniquePointKeys = new Set(polygonPoints.map((point) => `${point.x}:${point.y}`));
2722
+ if (uniquePointKeys.size !== polygonPoints.length) {
2723
+ return rejectInvalidMask("polygon points must not contain duplicates");
2724
+ }
2725
+ const doubleArea = polygonPoints.reduce((area, point, index) => {
2726
+ const nextPoint = polygonPoints[(index + 1) % polygonPoints.length];
2727
+ return area + point.x * nextPoint.y - nextPoint.x * point.y;
2728
+ }, 0);
2729
+ if (Math.abs(doubleArea) < 1e-6) {
2730
+ return rejectInvalidMask("polygon masks must have a non-zero area");
2358
2731
  }
2359
2732
  mask = new fabric.Polygon(polygonPoints, {
2360
2733
  left,
@@ -2368,11 +2741,17 @@ var ImageEditor = class {
2368
2741
  }
2369
2742
  case "rect":
2370
2743
  default:
2744
+ try {
2745
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, "width", "rx", { nonNegative: true });
2746
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, "height", "ry", { nonNegative: true });
2747
+ } catch (error) {
2748
+ return rejectInvalidMask("invalid rectangle corner radius", error);
2749
+ }
2371
2750
  mask = new fabric.Rect({
2372
2751
  left,
2373
2752
  top,
2374
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
2375
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
2753
+ width: maskConfig.width,
2754
+ height: maskConfig.height,
2376
2755
  fill: maskConfig.color,
2377
2756
  opacity: maskConfig.alpha,
2378
2757
  angle: maskConfig.angle,
@@ -2410,10 +2789,10 @@ var ImageEditor = class {
2410
2789
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2411
2790
  });
2412
2791
  this._rebindMaskEvents(mask);
2413
- this._expandCanvasToFitObject(mask);
2792
+ this._expandCanvasToFitObjects([mask]);
2414
2793
  this._lastMaskInitialLeft = left;
2415
2794
  this._lastMaskInitialTop = top;
2416
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2795
+ this._lastMaskInitialWidth = maskConfig.width;
2417
2796
  const maskId = ++this.maskCounter;
2418
2797
  mask.set({
2419
2798
  maskId,
@@ -2428,7 +2807,12 @@ var ImageEditor = class {
2428
2807
  this._updateUI();
2429
2808
  this.canvas.renderAll();
2430
2809
  this.saveState();
2431
- if (typeof maskConfig.onCreate === "function") maskConfig.onCreate(mask, this.canvas);
2810
+ if (typeof maskConfig.onCreate === "function") {
2811
+ this._emitSafeCallback(
2812
+ () => maskConfig.onCreate(mask, this.canvas),
2813
+ "createMask onCreate callback failed"
2814
+ );
2815
+ }
2432
2816
  return mask;
2433
2817
  }
2434
2818
  /**
@@ -2632,8 +3016,15 @@ var ImageEditor = class {
2632
3016
  this._removeLabelForMask(mask);
2633
3017
  let textObject = null;
2634
3018
  if (this.options.label && typeof this.options.label.create === "function") {
2635
- textObject = this.options.label.create(mask, fabric);
2636
- if (!textObject || typeof textObject.set !== "function") {
3019
+ let didLabelCreateThrow = false;
3020
+ try {
3021
+ textObject = this.options.label.create(mask, fabric);
3022
+ } catch (error) {
3023
+ didLabelCreateThrow = true;
3024
+ this._reportWarning("label.create() failed; using the default label", error);
3025
+ textObject = null;
3026
+ }
3027
+ if (!didLabelCreateThrow && (!textObject || typeof textObject.set !== "function")) {
2637
3028
  this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2638
3029
  textObject = null;
2639
3030
  }
@@ -2654,7 +3045,12 @@ var ImageEditor = class {
2654
3045
  };
2655
3046
  if (this.options.label) {
2656
3047
  if (typeof this.options.label.getText === "function") {
2657
- labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
3048
+ try {
3049
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
3050
+ } catch (error) {
3051
+ this._reportWarning("label.getText() failed; using the mask name", error);
3052
+ labelText = mask.maskName;
3053
+ }
2658
3054
  }
2659
3055
  if (this.options.label.textOptions) {
2660
3056
  Object.assign(textOptions, this.options.label.textOptions);
@@ -2841,6 +3237,7 @@ var ImageEditor = class {
2841
3237
  this._assertIdleForOperation("mergeMasks");
2842
3238
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2843
3239
  if (!masks.length) return;
3240
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
2844
3241
  const beforeJson = this._serializeCanvasState();
2845
3242
  const operationToken = this._beginBusyOperation("mergeMasks");
2846
3243
  this.canvas.discardActiveObject();
@@ -2859,12 +3256,13 @@ var ImageEditor = class {
2859
3256
  preserveScroll: true,
2860
3257
  resetMaskCounter: false
2861
3258
  }));
3259
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
2862
3260
  const afterJson = this._serializeCanvasState();
2863
3261
  this._pushStateTransition(beforeJson, afterJson);
2864
3262
  } catch (error) {
2865
3263
  this._reportError("merge error", error);
2866
3264
  try {
2867
- await this.loadFromState(beforeJson);
3265
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
2868
3266
  } catch (restoreError) {
2869
3267
  this._reportError("mergeMasks rollback failed", restoreError);
2870
3268
  }
@@ -2921,24 +3319,65 @@ var ImageEditor = class {
2921
3319
  */
2922
3320
  async exportImageBase64(options = {}) {
2923
3321
  if (!this.originalImage) throw new Error("No image loaded");
3322
+ options = options || {};
2924
3323
  this._assertIdleForOperation("exportImageBase64", options);
3324
+ const isNestedOperation = this._isOwnInternalOperation(options);
3325
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageBase64");
2925
3326
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2926
3327
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2927
3328
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
2928
3329
  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();
3330
+ try {
3331
+ if (!exportImageArea) {
3332
+ const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
3333
+ const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
3334
+ const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
3335
+ const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
3336
+ const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
3337
+ const activeObjectBackup2 = this._captureActiveObjectBackup();
3338
+ try {
3339
+ masks2.forEach((mask) => {
3340
+ mask.set({ visible: false });
3341
+ });
3342
+ this.canvas.discardActiveObject();
3343
+ this.canvas.renderAll();
3344
+ this.originalImage.setCoords();
3345
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
3346
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
3347
+ return await this._exportCanvasRegionToDataURL({
3348
+ ...exportRegion,
3349
+ multiplier,
3350
+ quality,
3351
+ format,
3352
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
3353
+ });
3354
+ } finally {
3355
+ maskVisibilityBackups.forEach((backup) => {
3356
+ try {
3357
+ backup.object.set({ visible: backup.visible });
3358
+ } catch (error) {
3359
+ void error;
3360
+ }
3361
+ });
3362
+ this._restoreMaskExportBackups(maskStyleBackups2);
3363
+ this._restoreMaskLabelBackups(labelBackups2);
3364
+ this._restoreActiveObjectBackup(activeObjectBackup2);
3365
+ this.canvas.renderAll();
3366
+ }
3367
+ }
3368
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3369
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3370
+ const labelBackups = this._captureMaskLabelBackups(masks);
3371
+ const activeObjectBackup = this._captureActiveObjectBackup();
2936
3372
  try {
2937
- masks2.forEach((mask) => {
2938
- mask.set({ visible: false });
2939
- });
3373
+ masks.forEach((mask) => this._removeLabelForMask(mask));
2940
3374
  this.canvas.discardActiveObject();
2941
3375
  this.canvas.renderAll();
3376
+ masks.forEach((mask) => {
3377
+ mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
3378
+ mask.setCoords();
3379
+ });
3380
+ this.canvas.renderAll();
2942
3381
  this.originalImage.setCoords();
2943
3382
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2944
3383
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
@@ -2950,47 +3389,13 @@ var ImageEditor = class {
2950
3389
  sealPartialEdges: this._getPartialExportEdges(imageBounds)
2951
3390
  });
2952
3391
  } 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);
3392
+ this._restoreMaskExportBackups(maskStyleBackups);
3393
+ this._restoreMaskLabelBackups(labelBackups);
3394
+ this._restoreActiveObjectBackup(activeObjectBackup);
2963
3395
  this.canvas.renderAll();
2964
3396
  }
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
3397
  } finally {
2990
- this._restoreMaskExportBackups(maskStyleBackups);
2991
- this._restoreMaskLabelBackups(labelBackups);
2992
- this._restoreActiveObjectBackup(activeObjectBackup);
2993
- this.canvas.renderAll();
3398
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
2994
3399
  }
2995
3400
  }
2996
3401
  /**
@@ -3023,7 +3428,10 @@ var ImageEditor = class {
3023
3428
  */
3024
3429
  async exportImageFile(options = {}) {
3025
3430
  if (!this.originalImage) throw new Error("No image loaded");
3026
- this._assertIdleForOperation("exportImageFile");
3431
+ options = options || {};
3432
+ this._assertIdleForOperation("exportImageFile", options);
3433
+ const isNestedOperation = this._isOwnInternalOperation(options);
3434
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageFile");
3027
3435
  const {
3028
3436
  mergeMask = true,
3029
3437
  fileType = "jpeg",
@@ -3033,48 +3441,52 @@ var ImageEditor = class {
3033
3441
  } = options;
3034
3442
  const safeFileType = this._normalizeImageFormat(fileType);
3035
3443
  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
- });
3444
+ try {
3445
+ let imageBase64;
3446
+ if (mergeMask) {
3447
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3448
+ exportImageArea: true,
3449
+ multiplier,
3450
+ quality: normalizedQuality,
3451
+ fileType: safeFileType
3452
+ }));
3453
+ } else {
3454
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3455
+ exportImageArea: false,
3456
+ multiplier,
3457
+ quality: normalizedQuality,
3458
+ fileType: safeFileType
3459
+ }));
3460
+ }
3461
+ let imageDataUrl = imageBase64;
3462
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3463
+ imageDataUrl = await new Promise((resolve, reject) => {
3464
+ const imageElement = new window.Image();
3465
+ imageElement.crossOrigin = "Anonymous";
3466
+ imageElement.onload = () => {
3467
+ try {
3468
+ const offscreenCanvas = document.createElement("canvas");
3469
+ offscreenCanvas.width = imageElement.width;
3470
+ offscreenCanvas.height = imageElement.height;
3471
+ const context = offscreenCanvas.getContext("2d");
3472
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
3473
+ context.drawImage(imageElement, 0, 0);
3474
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3475
+ resolve(convertedDataUrl);
3476
+ } catch (error) {
3477
+ reject(error);
3478
+ }
3479
+ };
3480
+ imageElement.onerror = reject;
3481
+ imageElement.src = imageBase64;
3482
+ });
3483
+ }
3484
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3485
+ const mime = `image/${safeFileType}`;
3486
+ return new File([bytes], fileName, { type: mime });
3487
+ } finally {
3488
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
3074
3489
  }
3075
- const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
3076
- const mime = `image/${safeFileType}`;
3077
- return new File([bytes], fileName, { type: mime });
3078
3490
  }
3079
3491
  _clearMaskPlacementMemory() {
3080
3492
  this._lastMask = null;
@@ -3082,7 +3494,7 @@ var ImageEditor = class {
3082
3494
  this._lastMaskInitialTop = null;
3083
3495
  this._lastMaskInitialWidth = null;
3084
3496
  }
3085
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
3497
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
3086
3498
  this._reportError(message, error);
3087
3499
  if (this._cropRect && this.canvas) this._removeCropRect();
3088
3500
  this._cropRect = null;
@@ -3093,7 +3505,7 @@ var ImageEditor = class {
3093
3505
  this._prevSelectionSetting = void 0;
3094
3506
  if (beforeJson) {
3095
3507
  try {
3096
- await this.loadFromState(beforeJson);
3508
+ await this.loadFromState(beforeJson, options);
3097
3509
  } catch (restoreError) {
3098
3510
  this._reportError("applyCrop: rollback failed", restoreError);
3099
3511
  }
@@ -3139,6 +3551,49 @@ var ImageEditor = class {
3139
3551
  this._cropRect = null;
3140
3552
  this._cropHandlers = [];
3141
3553
  }
3554
+ _getCropRectContentBounds(cropRect) {
3555
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
3556
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
3557
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
3558
+ return {
3559
+ left: Number(cropRect.left) || 0,
3560
+ top: Number(cropRect.top) || 0,
3561
+ width,
3562
+ height
3563
+ };
3564
+ }
3565
+ _getCropRectRawBounds(cropRect) {
3566
+ if (!cropRect) return { left: NaN, top: NaN, width: NaN, height: NaN };
3567
+ return {
3568
+ left: Number(cropRect.left),
3569
+ top: Number(cropRect.top),
3570
+ width: Number(cropRect.width) * Math.abs(Number(cropRect.scaleX)),
3571
+ height: Number(cropRect.height) * Math.abs(Number(cropRect.scaleY))
3572
+ };
3573
+ }
3574
+ _isValidCropRegion(cropBounds, imageBounds) {
3575
+ if (!cropBounds || !imageBounds) return false;
3576
+ const left = Number(cropBounds.left);
3577
+ const top = Number(cropBounds.top);
3578
+ const width = Number(cropBounds.width);
3579
+ const height = Number(cropBounds.height);
3580
+ const imageLeft = Number(imageBounds.left);
3581
+ const imageTop = Number(imageBounds.top);
3582
+ const imageWidth = Number(imageBounds.width);
3583
+ const imageHeight = Number(imageBounds.height);
3584
+ if (![left, top, width, height, imageLeft, imageTop, imageWidth, imageHeight].every(Number.isFinite)) return false;
3585
+ if (width <= 0 || height <= 0 || imageWidth <= 0 || imageHeight <= 0) return false;
3586
+ const right = left + width;
3587
+ const bottom = top + height;
3588
+ const imageRight = imageLeft + imageWidth;
3589
+ const imageBottom = imageTop + imageHeight;
3590
+ const overlapsImage = left < imageRight && right > imageLeft && top < imageBottom && bottom > imageTop;
3591
+ if (!overlapsImage) return false;
3592
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
3593
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
3594
+ if (!Number.isFinite(canvasWidth) || !Number.isFinite(canvasHeight) || canvasWidth <= 0 || canvasHeight <= 0) return false;
3595
+ return left < canvasWidth && right > 0 && top < canvasHeight && bottom > 0;
3596
+ }
3142
3597
  /**
3143
3598
  * Enters crop mode by creating a resizable crop rectangle above the base image.
3144
3599
  *
@@ -3150,6 +3605,10 @@ var ImageEditor = class {
3150
3605
  */
3151
3606
  enterCropMode() {
3152
3607
  if (!this.canvas || !this.originalImage || this._cropMode) return;
3608
+ if (this._isApplyingCrop) {
3609
+ this._reportWarning("enterCropMode ignored because a crop is already being applied");
3610
+ return;
3611
+ }
3153
3612
  if (!this._canMutateNow("enterCropMode")) return;
3154
3613
  if (!this.isImageLoaded()) return;
3155
3614
  this._removeCropRect();
@@ -3162,14 +3621,19 @@ var ImageEditor = class {
3162
3621
  const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
3163
3622
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
3164
3623
  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));
3624
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
3625
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
3167
3626
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3168
3627
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3169
3628
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3170
3629
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3171
3630
  const width = minCropWidth;
3172
3631
  const height = minCropHeight;
3632
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
3633
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
3634
+ this._cropRotationWarningEmitted = true;
3635
+ this._reportWarning("crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported");
3636
+ }
3173
3637
  const cropRect = new fabric.Rect({
3174
3638
  left,
3175
3639
  top,
@@ -3181,8 +3645,8 @@ var ImageEditor = class {
3181
3645
  strokeWidth: 1,
3182
3646
  strokeUniform: true,
3183
3647
  selectable: true,
3184
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3185
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
3648
+ hasRotatingPoint: false,
3649
+ lockRotation: true,
3186
3650
  cornerSize: 8,
3187
3651
  objectCaching: false,
3188
3652
  originX: "left",
@@ -3219,7 +3683,7 @@ var ImageEditor = class {
3219
3683
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3220
3684
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3221
3685
  cropRect.setCoords();
3222
- const cropBounds = cropRect.getBoundingRect(true, true);
3686
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3223
3687
  const imageLeft = Number(imageBounds.left) || 0;
3224
3688
  const imageTop = Number(imageBounds.top) || 0;
3225
3689
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3269,6 +3733,10 @@ var ImageEditor = class {
3269
3733
  * @public
3270
3734
  */
3271
3735
  cancelCrop() {
3736
+ if (this._isApplyingCrop) {
3737
+ this._reportWarning("cancelCrop ignored because a crop is already being applied");
3738
+ return;
3739
+ }
3272
3740
  if (!this.canvas || !this._cropMode) return;
3273
3741
  this._removeCropRect();
3274
3742
  this._restoreCropObjectState();
@@ -3292,95 +3760,120 @@ var ImageEditor = class {
3292
3760
  */
3293
3761
  async applyCrop() {
3294
3762
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3295
- 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();
3763
+ if (this._isApplyingCrop) {
3764
+ this._reportWarning("applyCrop ignored because a crop is already being applied");
3310
3765
  return;
3311
3766
  }
3312
- const preservedMasks = [];
3767
+ this._assertIdleForOperation("applyCrop");
3768
+ this._isApplyingCrop = true;
3769
+ const operationToken = this._beginBusyOperation("applyCrop");
3770
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3313
3771
  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();
3772
+ this._cropRect.setCoords();
3773
+ this.originalImage.setCoords();
3774
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
3775
+ const rawCropBounds = this._getCropRectRawBounds(this._cropRect);
3776
+ if (!this._isValidCropRegion(rawCropBounds, imageBounds)) {
3777
+ this._reportWarning("applyCrop: crop region is invalid");
3778
+ return;
3779
+ }
3780
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3781
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3782
+ const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
3783
+ this._restoreCropObjectState();
3784
+ let beforeJson;
3785
+ try {
3786
+ beforeJson = this._serializeCanvasState();
3787
+ } catch (error) {
3788
+ this._reportError("applyCrop: failed to capture rollback state", error);
3789
+ beforeJson = null;
3790
+ }
3791
+ if (!beforeJson) {
3792
+ this._removeCropRect();
3793
+ this._cropMode = false;
3794
+ this.canvas.selection = !!this._prevSelectionSetting;
3795
+ this._prevSelectionSetting = void 0;
3330
3796
  this.canvas.discardActiveObject();
3797
+ this._updateUI();
3331
3798
  this.canvas.renderAll();
3799
+ return;
3332
3800
  }
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);
3801
+ const preservedMasks = [];
3802
+ try {
3803
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3804
+ if (masks && masks.length) {
3805
+ masks.forEach((mask) => {
3806
+ mask.setCoords();
3807
+ const maskBounds = mask.getBoundingRect(true, true);
3808
+ 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;
3809
+ this._removeLabelForMask(mask);
3810
+ this._cleanupMaskEvents(mask);
3811
+ this.canvas.remove(mask);
3812
+ if (shouldPreserveMasks && intersectsCrop) {
3813
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3814
+ mask.set({ visible: true });
3815
+ preservedMasks.push(mask);
3816
+ }
3817
+ });
3818
+ this._clearMaskPlacementMemory();
3819
+ this.canvas.discardActiveObject();
3820
+ this.canvas.renderAll();
3821
+ }
3822
+ } catch (error) {
3823
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error, internalOptions);
3824
+ return;
3825
+ }
3826
+ this._removeCropRect();
3827
+ this._cropMode = false;
3828
+ this.canvas.selection = !!this._prevSelectionSetting;
3829
+ this._prevSelectionSetting = void 0;
3830
+ let croppedBase64;
3831
+ try {
3832
+ croppedBase64 = await this._exportCanvasRegionToDataURL({
3833
+ ...cropRegion,
3834
+ multiplier: 1,
3835
+ quality: this._normalizeQuality(this.options.downsampleQuality),
3836
+ format: "jpeg"
3360
3837
  });
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();
3838
+ } catch (error) {
3839
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error, internalOptions);
3840
+ return;
3365
3841
  }
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);
3842
+ try {
3843
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3844
+ if (preservedMasks.length) {
3845
+ preservedMasks.forEach((mask) => {
3846
+ this._rebindMaskEvents(mask);
3847
+ this.canvas.add(mask);
3848
+ this.canvas.bringToFront(mask);
3849
+ });
3850
+ this._lastMask = preservedMasks[preservedMasks.length - 1];
3851
+ this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
3852
+ this._updateMaskList();
3853
+ this.canvas.renderAll();
3854
+ }
3855
+ } catch (error) {
3856
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error, internalOptions);
3857
+ return;
3858
+ }
3859
+ let afterJson;
3860
+ try {
3861
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
3862
+ } catch (error) {
3863
+ this._reportWarning("applyCrop: failed to serialize after state", error);
3864
+ afterJson = null;
3865
+ }
3866
+ try {
3867
+ this._pushStateTransition(beforeJson, afterJson);
3868
+ } catch (error) {
3869
+ this._reportWarning("applyCrop: failed to push history command", error);
3870
+ }
3871
+ this._updateUI();
3872
+ this.canvas.renderAll();
3873
+ } finally {
3874
+ this._isApplyingCrop = false;
3875
+ this._endBusyOperation(operationToken);
3381
3876
  }
3382
- this._updateUI();
3383
- this.canvas.renderAll();
3384
3877
  }
3385
3878
  /* ---------- Misc / UI ---------- */
3386
3879
  /**
@@ -3410,9 +3903,11 @@ var ImageEditor = class {
3410
3903
  const isInCropMode = !!this._cropMode;
3411
3904
  const isBusy = this.isBusy();
3412
3905
  if (isInCropMode) {
3906
+ const cropInteractionKeys = /* @__PURE__ */ new Set(["canvas", "canvasContainer", "imagePlaceholder", "imgPlaceholder"]);
3413
3907
  for (const key of Object.keys(this.elements || {})) {
3414
3908
  const element = this._getElement(key);
3415
3909
  if (!element) continue;
3910
+ if (cropInteractionKeys.has(key)) continue;
3416
3911
  if (key === "applyCropButton" || key === "cancelCropButton" || key === "applyCropBtn" || key === "cancelCropBtn") {
3417
3912
  this._setDisabled(key, false);
3418
3913
  } else {
@@ -3450,9 +3945,44 @@ var ImageEditor = class {
3450
3945
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3451
3946
  * @private
3452
3947
  */
3948
+ _rememberElementDisabledState(key, element) {
3949
+ if (!element) return;
3950
+ if (!this._elementOriginalDisabledState) this._elementOriginalDisabledState = /* @__PURE__ */ new Map();
3951
+ if (this._elementOriginalDisabledState.has(key)) return;
3952
+ this._elementOriginalDisabledState.set(key, {
3953
+ element,
3954
+ hasDisabledProperty: "disabled" in element,
3955
+ disabled: "disabled" in element ? !!element.disabled : void 0,
3956
+ ariaDisabled: element.getAttribute ? element.getAttribute("aria-disabled") : null,
3957
+ pointerEvents: element.style ? element.style.pointerEvents || "" : ""
3958
+ });
3959
+ }
3960
+ _restoreElementDisabledStates() {
3961
+ if (!this._elementOriginalDisabledState) return;
3962
+ for (const state of this._elementOriginalDisabledState.values()) {
3963
+ const element = state && state.element;
3964
+ if (!element) continue;
3965
+ try {
3966
+ if (state.hasDisabledProperty && "disabled" in element) {
3967
+ element.disabled = !!state.disabled;
3968
+ }
3969
+ if (element.getAttribute && element.setAttribute && element.removeAttribute) {
3970
+ if (state.ariaDisabled === null) {
3971
+ element.removeAttribute("aria-disabled");
3972
+ } else {
3973
+ element.setAttribute("aria-disabled", state.ariaDisabled);
3974
+ }
3975
+ }
3976
+ if (element.style) element.style.pointerEvents = state.pointerEvents || "";
3977
+ } catch (error) {
3978
+ void error;
3979
+ }
3980
+ }
3981
+ }
3453
3982
  _setDisabled(key, disabled) {
3454
3983
  const element = this._getElement(key);
3455
3984
  if (!element) return;
3985
+ this._rememberElementDisabledState(key, element);
3456
3986
  if ("disabled" in element) {
3457
3987
  element.disabled = !!disabled;
3458
3988
  return;
@@ -3479,7 +4009,6 @@ var ImageEditor = class {
3479
4009
  * @private
3480
4010
  */
3481
4011
  _updatePlaceholderStatus() {
3482
- if (!this.options.showPlaceholder) return;
3483
4012
  this._setPlaceholderVisible(!this.originalImage);
3484
4013
  }
3485
4014
  /**
@@ -3489,10 +4018,11 @@ var ImageEditor = class {
3489
4018
  * @private
3490
4019
  */
3491
4020
  _setPlaceholderVisible(show) {
3492
- if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
4021
+ const shouldShowPlaceholder = !!show && this.options.showPlaceholder !== false;
4022
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, shouldShowPlaceholder);
3493
4023
  const canvasVisibilityElement = this._getCanvasVisibilityElement();
3494
4024
  if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
3495
- this._setElementVisible(canvasVisibilityElement, !show);
4025
+ this._setElementVisible(canvasVisibilityElement, !shouldShowPlaceholder);
3496
4026
  }
3497
4027
  }
3498
4028
  _getCanvasVisibilityElement() {
@@ -3571,6 +4101,12 @@ var ImageEditor = class {
3571
4101
  void error;
3572
4102
  }
3573
4103
  if (this._cropRect) this._removeCropRect();
4104
+ this._isApplyingCrop = false;
4105
+ try {
4106
+ this._restoreElementDisabledStates();
4107
+ } catch (error) {
4108
+ void error;
4109
+ }
3574
4110
  if (this.containerElement && this._containerOriginalOverflow) {
3575
4111
  try {
3576
4112
  this._restoreContainerOverflowState();
@@ -3618,6 +4154,7 @@ var ImageEditor = class {
3618
4154
  this._handlersByElementKey = {};
3619
4155
  this._elementCache = {};
3620
4156
  this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
4157
+ this._elementOriginalDisabledState = /* @__PURE__ */ new Map();
3621
4158
  this._clearMaskPlacementMemory();
3622
4159
  this.originalImage = null;
3623
4160
  this.baseImageScale = 1;
@@ -3626,6 +4163,7 @@ var ImageEditor = class {
3626
4163
  this.isAnimating = false;
3627
4164
  this._isLoading = false;
3628
4165
  this._cropMode = false;
4166
+ this._isApplyingCrop = false;
3629
4167
  this._cropRect = null;
3630
4168
  this._cropHandlers = [];
3631
4169
  this._cropPrevEvented = null;
@@ -3728,9 +4266,10 @@ var HistoryManager = class {
3728
4266
  * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
3729
4267
  */
3730
4268
  constructor(maxSize = 50) {
4269
+ const numericMaxSize = Number(maxSize);
3731
4270
  this.history = [];
3732
4271
  this.currentIndex = -1;
3733
- this.maxSize = maxSize;
4272
+ this.maxSize = Number.isFinite(numericMaxSize) && numericMaxSize > 0 ? Math.floor(numericMaxSize) : 50;
3734
4273
  this.pending = Promise.resolve();
3735
4274
  }
3736
4275
  /**
@@ -3800,11 +4339,11 @@ var HistoryManager = class {
3800
4339
  *
3801
4340
  * @returns {Promise<void>} Resolves after the undo task completes.
3802
4341
  */
3803
- undo() {
4342
+ undo(options = {}) {
3804
4343
  return this.enqueue(async () => {
3805
4344
  if (this.currentIndex >= 0) {
3806
4345
  const index = this.currentIndex;
3807
- await this.history[index].undo();
4346
+ await this.history[index].undo(options);
3808
4347
  this.currentIndex = index - 1;
3809
4348
  }
3810
4349
  });
@@ -3814,11 +4353,11 @@ var HistoryManager = class {
3814
4353
  *
3815
4354
  * @returns {Promise<void>} Resolves after the redo task completes.
3816
4355
  */
3817
- redo() {
4356
+ redo(options = {}) {
3818
4357
  return this.enqueue(async () => {
3819
4358
  if (this.currentIndex < this.history.length - 1) {
3820
4359
  const index = this.currentIndex + 1;
3821
- await this.history[index].execute();
4360
+ await this.history[index].execute(options);
3822
4361
  this.currentIndex = index;
3823
4362
  }
3824
4363
  });