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