@bensitu/image-editor 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.4.2
8
+ * @version 1.5.1
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -77,6 +77,7 @@ var ImageEditor = class {
77
77
  downsampleMimeType: null,
78
78
  imageLoadTimeoutMs: 3e4,
79
79
  exportMultiplier: 1,
80
+ maxExportPixels: 5e7,
80
81
  exportImageAreaByDefault: true,
81
82
  defaultMaskWidth: 50,
82
83
  defaultMaskHeight: 80,
@@ -146,7 +147,9 @@ var ImageEditor = class {
146
147
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
148
  this._disposed = false;
148
149
  this._initialized = false;
149
- this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
150
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
151
+ this._cropRotationWarningEmitted = false;
152
+ this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
150
153
  this.animationQueue = new AnimationQueue();
151
154
  this.historyManager = new HistoryManager(this.maxHistorySize);
152
155
  }
@@ -192,10 +195,12 @@ var ImageEditor = class {
192
195
  * Use this method to set up the editor UI before interacting with it.
193
196
  *
194
197
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
195
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
196
- * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
197
- * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
198
- * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
198
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
199
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
200
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
201
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
202
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
203
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
199
204
  *
200
205
  * @returns {void}
201
206
  *
@@ -204,11 +209,17 @@ var ImageEditor = class {
204
209
  * @example
205
210
  * editor.init({
206
211
  * canvas: 'myFabricCanvasId',
207
- * downloadBtn: 'myDownloadButtonId'
212
+ * downloadImageButton: 'myDownloadButtonId'
208
213
  * });
209
214
  */
210
215
  init(idMap = {}) {
211
- if (!this._fabricLoaded) return;
216
+ if (!this._fabricLoaded) {
217
+ this._fabricLoaded = !!ensureFabric();
218
+ if (!this._fabricLoaded) {
219
+ this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
220
+ return;
221
+ }
222
+ }
212
223
  if (this._initialized || this.canvas) this.dispose();
213
224
  this._disposed = false;
214
225
  this._initialized = true;
@@ -227,29 +238,49 @@ var ImageEditor = class {
227
238
  canvas: "fabricCanvas",
228
239
  canvasContainer: null,
229
240
  // Pass an ID here if you have a scrollable viewport container
230
- imgPlaceholder: "imgPlaceholder",
231
- scaleRate: "scaleRate",
232
- rotationLeftInput: "rotationLeftInput",
233
- rotationRightInput: "rotationRightInput",
234
- rotateLeftBtn: "rotateLeftBtn",
235
- rotateRightBtn: "rotateRightBtn",
236
- addMaskBtn: "addMaskBtn",
237
- removeMaskBtn: "removeMaskBtn",
238
- removeAllMasksBtn: "removeAllMasksBtn",
239
- mergeBtn: "mergeBtn",
240
- downloadBtn: "downloadBtn",
241
+ imagePlaceholder: "imagePlaceholder",
242
+ imgPlaceholder: null,
243
+ scalePercentageInput: "scalePercentageInput",
244
+ scaleRate: null,
245
+ rotateLeftDegreesInput: "rotateLeftDegreesInput",
246
+ rotationLeftInput: null,
247
+ rotateRightDegreesInput: "rotateRightDegreesInput",
248
+ rotationRightInput: null,
249
+ rotateLeftButton: "rotateLeftButton",
250
+ rotateLeftBtn: null,
251
+ rotateRightButton: "rotateRightButton",
252
+ rotateRightBtn: null,
253
+ createMaskButton: "createMaskButton",
254
+ addMaskBtn: null,
255
+ removeSelectedMaskButton: "removeSelectedMaskButton",
256
+ removeMaskBtn: null,
257
+ removeAllMasksButton: "removeAllMasksButton",
258
+ removeAllMasksBtn: null,
259
+ mergeMasksButton: "mergeMasksButton",
260
+ mergeBtn: null,
261
+ downloadImageButton: "downloadImageButton",
262
+ downloadBtn: null,
241
263
  maskList: "maskList",
242
- zoomInBtn: "zoomInBtn",
243
- zoomOutBtn: "zoomOutBtn",
244
- resetBtn: "resetBtn",
245
- undoBtn: "undoBtn",
246
- redoBtn: "redoBtn",
264
+ zoomInButton: "zoomInButton",
265
+ zoomInBtn: null,
266
+ zoomOutButton: "zoomOutButton",
267
+ zoomOutBtn: null,
268
+ resetImageTransformButton: "resetImageTransformButton",
269
+ resetBtn: null,
270
+ undoButton: "undoButton",
271
+ undoBtn: null,
272
+ redoButton: "redoButton",
273
+ redoBtn: null,
247
274
  imageInput: "imageInput",
248
- cropBtn: "cropBtn",
249
- applyCropBtn: "applyCropBtn",
250
- cancelCropBtn: "cancelCropBtn"
275
+ uploadArea: null,
276
+ enterCropModeButton: "enterCropModeButton",
277
+ cropBtn: null,
278
+ applyCropButton: "applyCropButton",
279
+ applyCropBtn: null,
280
+ cancelCropButton: "cancelCropButton",
281
+ cancelCropBtn: null
251
282
  };
252
- this.elements = { ...defaults, ...idMap };
283
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
253
284
  this._elementCache = {};
254
285
  this._initCanvas();
255
286
  this._bindEvents();
@@ -257,11 +288,68 @@ var ImageEditor = class {
257
288
  this._updateMaskList();
258
289
  this._updateUI();
259
290
  if (this.options.initialImageBase64) {
260
- this.loadImage(this.options.initialImageBase64);
291
+ this.loadImage(this.options.initialImageBase64).catch((error) => this._reportError("initialImageBase64 could not be loaded", error));
261
292
  } else {
262
293
  this._updatePlaceholderStatus();
263
294
  }
264
295
  }
296
+ _resolveElementIdMap(idMap, defaults) {
297
+ const resolved = { ...defaults, ...idMap };
298
+ this._resolveElementAliases(resolved, idMap, defaults, "imagePlaceholder", ["imgPlaceholder"]);
299
+ this._resolveElementAliases(resolved, idMap, defaults, "scalePercentageInput", ["scaleRate"]);
300
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateLeftDegreesInput", ["rotationLeftInput"]);
301
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateRightDegreesInput", ["rotationRightInput"]);
302
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateLeftButton", "rotateLeftBtn");
303
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateRightButton", "rotateRightBtn");
304
+ this._resolveElementAlias(resolved, idMap, defaults, "createMaskButton", "addMaskBtn");
305
+ this._resolveElementAliases(resolved, idMap, defaults, "removeSelectedMaskButton", ["removeMaskBtn"]);
306
+ this._resolveElementAlias(resolved, idMap, defaults, "removeAllMasksButton", "removeAllMasksBtn");
307
+ this._resolveElementAlias(resolved, idMap, defaults, "mergeMasksButton", "mergeBtn");
308
+ this._resolveElementAliases(resolved, idMap, defaults, "downloadImageButton", ["downloadBtn"]);
309
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomInButton", "zoomInBtn");
310
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomOutButton", "zoomOutBtn");
311
+ this._resolveElementAlias(resolved, idMap, defaults, "resetImageTransformButton", "resetBtn");
312
+ this._resolveElementAlias(resolved, idMap, defaults, "undoButton", "undoBtn");
313
+ this._resolveElementAlias(resolved, idMap, defaults, "redoButton", "redoBtn");
314
+ this._resolveElementAliases(resolved, idMap, defaults, "enterCropModeButton", ["cropBtn"]);
315
+ this._resolveElementAlias(resolved, idMap, defaults, "applyCropButton", "applyCropBtn");
316
+ this._resolveElementAlias(resolved, idMap, defaults, "cancelCropButton", "cancelCropBtn");
317
+ return resolved;
318
+ }
319
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
320
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
321
+ }
322
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
323
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
324
+ if (hasCanonicalKey) {
325
+ resolved[canonicalKey] = idMap[canonicalKey];
326
+ return;
327
+ }
328
+ let deprecatedValue;
329
+ let hasDeprecatedValue = false;
330
+ for (const deprecatedKey of deprecatedKeys) {
331
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
332
+ if (!hasDeprecatedValue) {
333
+ deprecatedValue = idMap[deprecatedKey];
334
+ hasDeprecatedValue = true;
335
+ }
336
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
337
+ }
338
+ }
339
+ if (hasDeprecatedValue) {
340
+ resolved[canonicalKey] = deprecatedValue;
341
+ return;
342
+ }
343
+ resolved[canonicalKey] = defaults[canonicalKey];
344
+ }
345
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
346
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
347
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
348
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
349
+ this._reportWarning(
350
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
351
+ );
352
+ }
265
353
  _reportError(message, error = null) {
266
354
  const handler = this.options && this.options.onError;
267
355
  if (typeof handler !== "function") return;
@@ -278,6 +366,11 @@ var ImageEditor = class {
278
366
  } catch {
279
367
  }
280
368
  }
369
+ _notifyImageLoaded() {
370
+ const optionsCallback = this.options && this.options.onImageLoaded;
371
+ const callback = typeof optionsCallback === "function" ? optionsCallback : this.onImageLoaded;
372
+ if (typeof callback === "function") callback();
373
+ }
281
374
  /**
282
375
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
283
376
  *
@@ -300,7 +393,7 @@ var ImageEditor = class {
300
393
  } else {
301
394
  this.containerElement = canvasElement.parentElement;
302
395
  }
303
- this.placeholderElement = this._getElement("imgPlaceholder") || null;
396
+ this.placeholderElement = this._getElement("imagePlaceholder") || null;
304
397
  let initialWidth = this.options.canvasWidth;
305
398
  let initialHeight = this.options.canvasHeight;
306
399
  if (this.containerElement) {
@@ -396,13 +489,14 @@ var ImageEditor = class {
396
489
  if (!this.containerElement || !this.containerElement.style) return;
397
490
  this._captureContainerOverflowState();
398
491
  const shouldPreserveScroll = options.preserveScroll === true;
399
- if (this.options.coverImageToCanvas) {
492
+ const layoutMode = this._getImageLayoutMode();
493
+ if (layoutMode === "cover") {
400
494
  this.containerElement.style.overflow = "scroll";
401
495
  if (!shouldPreserveScroll) {
402
496
  this.containerElement.scrollLeft = 0;
403
497
  this.containerElement.scrollTop = 0;
404
498
  }
405
- } else if (this.options.fitImageToCanvas) {
499
+ } else if (layoutMode === "fit") {
406
500
  this.containerElement.style.overflow = "auto";
407
501
  if (!shouldPreserveScroll) {
408
502
  this.containerElement.scrollLeft = 0;
@@ -450,20 +544,20 @@ var ImageEditor = class {
450
544
  });
451
545
  }
452
546
  });
453
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
454
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
455
- this._bindIfExists("resetBtn", "click", () => {
547
+ this._bindIfExists("zoomInButton", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
548
+ this._bindIfExists("zoomOutButton", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
549
+ this._bindIfExists("resetImageTransformButton", "click", () => {
456
550
  this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
457
551
  });
458
- this._bindIfExists("addMaskBtn", "click", () => this.createMask());
459
- this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
460
- this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
461
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
462
- this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
463
- this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
464
- this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
465
- this._bindIfExists("rotateLeftBtn", "click", () => {
466
- const rotationInputElement = this._getElement("rotationLeftInput");
552
+ this._bindIfExists("createMaskButton", "click", () => this.createMask());
553
+ this._bindIfExists("removeSelectedMaskButton", "click", () => this.removeSelectedMask());
554
+ this._bindIfExists("removeAllMasksButton", "click", () => this.removeAllMasks());
555
+ this._bindIfExists("mergeMasksButton", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
556
+ this._bindIfExists("downloadImageButton", "click", () => this.downloadImage());
557
+ this._bindIfExists("undoButton", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
558
+ this._bindIfExists("redoButton", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
559
+ this._bindIfExists("rotateLeftButton", "click", () => {
560
+ const rotationInputElement = this._getElement("rotateLeftDegreesInput");
467
561
  let step = this.options.rotationStep;
468
562
  if (rotationInputElement) {
469
563
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -471,8 +565,8 @@ var ImageEditor = class {
471
565
  }
472
566
  this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
473
567
  });
474
- this._bindIfExists("rotateRightBtn", "click", () => {
475
- const rotationInputElement = this._getElement("rotationRightInput");
568
+ this._bindIfExists("rotateRightButton", "click", () => {
569
+ const rotationInputElement = this._getElement("rotateRightDegreesInput");
476
570
  let step = this.options.rotationStep;
477
571
  if (rotationInputElement) {
478
572
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -480,11 +574,11 @@ var ImageEditor = class {
480
574
  }
481
575
  this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
482
576
  });
483
- this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
484
- this._bindIfExists("applyCropBtn", "click", () => {
577
+ this._bindIfExists("enterCropModeButton", "click", () => this.enterCropMode());
578
+ this._bindIfExists("applyCropButton", "click", () => {
485
579
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
486
580
  });
487
- this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
581
+ this._bindIfExists("cancelCropButton", "click", () => this.cancelCrop());
488
582
  this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
489
583
  }
490
584
  /**
@@ -553,6 +647,12 @@ var ImageEditor = class {
553
647
  `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
554
648
  );
555
649
  }
650
+ _getImageLayoutMode() {
651
+ if (this.options.fitImageToCanvas) return "fit";
652
+ if (this.options.coverImageToCanvas) return "cover";
653
+ if (this.options.expandCanvasToImage) return "expand";
654
+ return "contain";
655
+ }
556
656
  /**
557
657
  * Loads a base64 data URL into the Fabric canvas as the base image.
558
658
  *
@@ -566,12 +666,16 @@ var ImageEditor = class {
566
666
  if (!this._fabricLoaded) return;
567
667
  if (!this.canvas || this._disposed) return;
568
668
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
669
+ options = options || {};
569
670
  this._assertIdleForOperation("loadImage", options);
570
- this._isLoading = true;
571
- this._updateUI();
572
- this._warnOnImageLayoutOptionConflict();
573
- const transaction = this._captureLoadImageTransaction();
671
+ const isNestedOperation = this._isOwnInternalOperation(options);
672
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("loadImage");
673
+ let transaction = null;
574
674
  try {
675
+ this._isLoading = true;
676
+ this._updateUI();
677
+ this._warnOnImageLayoutOptionConflict();
678
+ transaction = this._captureLoadImageTransaction();
575
679
  const imageElement = await this._createImageElement(imageBase64);
576
680
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
577
681
  let loadSource = imageBase64;
@@ -611,7 +715,8 @@ var ImageEditor = class {
611
715
  const viewport = this._getContainerViewportSize();
612
716
  const minWidth = viewport.width;
613
717
  const minHeight = viewport.height;
614
- if (this.options.fitImageToCanvas) {
718
+ const layoutMode = this._getImageLayoutMode();
719
+ if (layoutMode === "fit") {
615
720
  const canvasWidth = Math.max(1, minWidth - 1);
616
721
  const canvasHeight = Math.max(1, minHeight - 1);
617
722
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -619,13 +724,13 @@ var ImageEditor = class {
619
724
  fabricImage.set({ left: 0, top: 0 });
620
725
  fabricImage.scale(fitScale);
621
726
  this.baseImageScale = fabricImage.scaleX || 1;
622
- } else if (this.options.coverImageToCanvas) {
727
+ } else if (layoutMode === "cover") {
623
728
  const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
624
729
  this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
625
730
  fabricImage.set({ left: 0, top: 0 });
626
731
  fabricImage.scale(layout.scale);
627
732
  this.baseImageScale = fabricImage.scaleX || 1;
628
- } else if (this.options.expandCanvasToImage) {
733
+ } else if (layoutMode === "expand") {
629
734
  const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
630
735
  const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
631
736
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
@@ -654,14 +759,16 @@ var ImageEditor = class {
654
759
  this._updateUI();
655
760
  this.canvas.renderAll();
656
761
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
657
- if (typeof this.onImageLoaded === "function") {
658
- this.onImageLoaded();
659
- }
762
+ this._notifyImageLoaded();
660
763
  } catch (error) {
661
- await this._rollbackLoadImageTransaction(transaction);
764
+ await this._rollbackLoadImageTransaction(
765
+ transaction,
766
+ this._withInternalOperationOptions(operationToken)
767
+ );
662
768
  throw error;
663
769
  } finally {
664
770
  this._isLoading = false;
771
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
665
772
  if (!this._disposed && this.canvas) this._updateUI();
666
773
  }
667
774
  }
@@ -709,7 +816,7 @@ var ImageEditor = class {
709
816
  try {
710
817
  imageElement.src = "";
711
818
  } catch (error) {
712
- void error;
819
+ this._reportWarning("Image timeout cleanup failed", error);
713
820
  }
714
821
  }, safeTimeoutMs);
715
822
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -774,32 +881,38 @@ var ImageEditor = class {
774
881
  canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
775
882
  };
776
883
  }
777
- async _rollbackLoadImageTransaction(transaction) {
884
+ async _rollbackLoadImageTransaction(transaction, options = {}) {
778
885
  if (!transaction || !this.canvas || this._disposed) return;
779
886
  let didRestoreCanvasState = false;
887
+ let didFailCanvasRestore = false;
780
888
  try {
781
889
  if (transaction.canvasState) {
782
- await this.loadFromState(transaction.canvasState);
890
+ await this.loadFromState(transaction.canvasState, options);
783
891
  didRestoreCanvasState = true;
784
892
  }
785
893
  } catch (error) {
786
894
  this._lastMask = null;
895
+ didFailCanvasRestore = true;
787
896
  this._reportError("loadImage rollback failed", error);
788
897
  }
789
- this.baseImageScale = transaction.baseImageScale;
790
- this.currentScale = transaction.currentScale;
791
- this.currentRotation = transaction.currentRotation;
792
- this.maskCounter = transaction.maskCounter;
793
- this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
794
- this._lastSnapshot = transaction.lastSnapshot;
795
- if (didRestoreCanvasState) {
796
- this._restoreLastMaskReference(transaction.lastMask);
898
+ if (didFailCanvasRestore) {
899
+ this._reconcileEditorStateFromCanvas();
797
900
  } else {
798
- this._lastMask = null;
901
+ this.baseImageScale = transaction.baseImageScale;
902
+ this.currentScale = transaction.currentScale;
903
+ this.currentRotation = transaction.currentRotation;
904
+ this.maskCounter = transaction.maskCounter;
905
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
906
+ this._lastSnapshot = transaction.lastSnapshot;
907
+ if (didRestoreCanvasState) {
908
+ this._restoreLastMaskReference(transaction.lastMask);
909
+ } else {
910
+ this._lastMask = null;
911
+ }
912
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
913
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
914
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
799
915
  }
800
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
801
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
802
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
803
916
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
804
917
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
805
918
  if (this.containerElement) {
@@ -812,6 +925,46 @@ var ImageEditor = class {
812
925
  this._updateUI();
813
926
  if (this.canvas) this.canvas.renderAll();
814
927
  }
928
+ _reconcileEditorStateFromCanvas() {
929
+ if (!this.canvas) {
930
+ this.originalImage = null;
931
+ this.baseImageScale = 1;
932
+ this.currentScale = 1;
933
+ this.currentRotation = 0;
934
+ this.maskCounter = 0;
935
+ this.isImageLoadedToCanvas = false;
936
+ this._lastSnapshot = null;
937
+ this._clearMaskPlacementMemory();
938
+ return;
939
+ }
940
+ const canvasObjects = this.canvas.getObjects();
941
+ this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
942
+ if (this.originalImage) {
943
+ const imageScale = Number(this.originalImage.scaleX) || 1;
944
+ this.baseImageScale = imageScale;
945
+ this.currentScale = 1;
946
+ this.currentRotation = Number(this.originalImage.angle) || 0;
947
+ } else {
948
+ this.baseImageScale = 1;
949
+ this.currentScale = 1;
950
+ this.currentRotation = 0;
951
+ }
952
+ const masks = canvasObjects.filter((object) => object.maskId);
953
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
954
+ this._lastMask = masks[masks.length - 1] || null;
955
+ if (!this._lastMask) {
956
+ this._lastMaskInitialLeft = null;
957
+ this._lastMaskInitialTop = null;
958
+ this._lastMaskInitialWidth = null;
959
+ }
960
+ this.isImageLoadedToCanvas = !!this.originalImage;
961
+ try {
962
+ this._lastSnapshot = this._serializeCanvasState();
963
+ } catch (error) {
964
+ this._lastSnapshot = null;
965
+ this._reportWarning("loadImage rollback: failed to reconcile canvas snapshot", error);
966
+ }
967
+ }
815
968
  _restoreLastMaskReference(previousLastMask) {
816
969
  if (!this.canvas) {
817
970
  this._lastMask = null;
@@ -882,6 +1035,7 @@ var ImageEditor = class {
882
1035
  * @private
883
1036
  */
884
1037
  _setCanvasSizeInt(width, height) {
1038
+ if (!this.canvas) return;
885
1039
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
886
1040
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
887
1041
  this.canvas.setWidth(integerWidth);
@@ -983,9 +1137,9 @@ var ImageEditor = class {
983
1137
  }
984
1138
  _getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
985
1139
  if (this._hasFixedContainerScrollbars()) {
986
- const safetyMargin = this._getScrollSafetyMargin();
987
- const safeWidth = Math.max(1, viewport.width - safetyMargin);
988
- const safeHeight = Math.max(1, viewport.height - safetyMargin);
1140
+ const safetyMargin2 = this._getScrollSafetyMargin();
1141
+ const safeWidth = Math.max(1, viewport.width - safetyMargin2);
1142
+ const safeHeight = Math.max(1, viewport.height - safetyMargin2);
989
1143
  return {
990
1144
  width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
991
1145
  height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
@@ -1011,9 +1165,17 @@ var ImageEditor = class {
1011
1165
  }
1012
1166
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1013
1167
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1168
+ const safetyMargin = this._getScrollSafetyMargin();
1169
+ const layoutMode = this._getImageLayoutMode();
1170
+ const shouldReserveNoScrollbarMargin = layoutMode === "fit" || layoutMode === "cover";
1171
+ const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
1172
+ const margin = hasOppositeScrollbar ? safetyMargin : shouldReserveNoScrollbarMargin ? 1 : 0;
1173
+ const safeEffectiveSize = Math.max(1, effectiveSize - margin);
1174
+ return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
1175
+ };
1014
1176
  return {
1015
- width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
1016
- height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
1177
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
1178
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
1017
1179
  viewportWidth: effectiveWidth,
1018
1180
  viewportHeight: effectiveHeight,
1019
1181
  hasHorizontal,
@@ -1135,6 +1297,45 @@ var ImageEditor = class {
1135
1297
  });
1136
1298
  }
1137
1299
  }
1300
+ _getSerializableStateObjects() {
1301
+ if (!this.canvas) return [];
1302
+ return this.canvas.getObjects().filter((object) => !object.isCropRect && !object.maskLabel);
1303
+ }
1304
+ _restoreHighPrecisionSerializedGeometry(serializedObjects) {
1305
+ if (!Array.isArray(serializedObjects)) return;
1306
+ const fabricObjects = this._getSerializableStateObjects();
1307
+ const numericProperties = [
1308
+ "left",
1309
+ "top",
1310
+ "width",
1311
+ "height",
1312
+ "scaleX",
1313
+ "scaleY",
1314
+ "angle",
1315
+ "skewX",
1316
+ "skewY",
1317
+ "cropX",
1318
+ "cropY",
1319
+ "radius",
1320
+ "rx",
1321
+ "ry",
1322
+ "strokeWidth"
1323
+ ];
1324
+ serializedObjects.forEach((serializedObject, index) => {
1325
+ const fabricObject = fabricObjects[index];
1326
+ if (!serializedObject || !fabricObject) return;
1327
+ numericProperties.forEach((property) => {
1328
+ const numericValue = Number(fabricObject[property]);
1329
+ if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
1330
+ });
1331
+ if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
1332
+ serializedObject.points = fabricObject.points.map((point) => ({
1333
+ x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
1334
+ y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
1335
+ }));
1336
+ }
1337
+ });
1338
+ }
1138
1339
  _restoreMaskControls(mask) {
1139
1340
  if (!mask) return;
1140
1341
  const cornerSize = Number(mask.cornerSize);
@@ -1154,7 +1355,7 @@ var ImageEditor = class {
1154
1355
  /**
1155
1356
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1156
1357
  *
1157
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1358
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1158
1359
  * @private
1159
1360
  */
1160
1361
  _serializeEditorMetadata() {
@@ -1162,12 +1363,16 @@ var ImageEditor = class {
1162
1363
  const currentScale = Number(this.currentScale);
1163
1364
  const currentRotation = Number(this.currentRotation);
1164
1365
  const maskCounter = Number(this.maskCounter);
1366
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1367
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1165
1368
  return {
1166
1369
  version: 1,
1167
1370
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1168
1371
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1169
1372
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1170
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1373
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1374
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1375
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1171
1376
  };
1172
1377
  }
1173
1378
  _serializeCanvasState() {
@@ -1176,6 +1381,7 @@ var ImageEditor = class {
1176
1381
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
1177
1382
  if (Array.isArray(jsonObject.objects)) {
1178
1383
  jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
1384
+ this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
1179
1385
  }
1180
1386
  jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1181
1387
  return JSON.stringify(jsonObject);
@@ -1250,6 +1456,12 @@ var ImageEditor = class {
1250
1456
  if (!Number.isFinite(numericValue)) return false;
1251
1457
  return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1252
1458
  }
1459
+ _hasScaledImageEdge(axis) {
1460
+ if (!this.originalImage) return false;
1461
+ const scale = Number(axis === "y" ? this.originalImage.scaleY : this.originalImage.scaleX);
1462
+ if (!Number.isFinite(scale)) return false;
1463
+ return Math.abs(scale - 1) > 0.01;
1464
+ }
1253
1465
  _getPartialExportEdges(bounds) {
1254
1466
  if (!bounds) return null;
1255
1467
  const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
@@ -1258,8 +1470,8 @@ var ImageEditor = class {
1258
1470
  return {
1259
1471
  left: this._hasFractionalCanvasEdge(bounds.left),
1260
1472
  top: this._hasFractionalCanvasEdge(bounds.top),
1261
- right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1262
- bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1473
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge("x"),
1474
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge("y")
1263
1475
  };
1264
1476
  }
1265
1477
  async _sealPartialTransparentEdges(dataUrl, edges) {
@@ -1319,7 +1531,8 @@ var ImageEditor = class {
1319
1531
  * @private
1320
1532
  */
1321
1533
  async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1322
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1534
+ const safeMultiplier = this._getSafeExportMultiplier(multiplier);
1535
+ this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
1323
1536
  const safeFormat = this._normalizeImageFormat(format);
1324
1537
  const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1325
1538
  let regionDataUrl = this.canvas.toDataURL({
@@ -1335,6 +1548,25 @@ var ImageEditor = class {
1335
1548
  if (safeFormat !== "jpeg") return regionDataUrl;
1336
1549
  return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1337
1550
  }
1551
+ _getSafeExportMultiplier(multiplier) {
1552
+ const numericMultiplier = Number(multiplier);
1553
+ if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
1554
+ throw new Error("Export multiplier must be a finite positive number");
1555
+ }
1556
+ return Math.max(1, numericMultiplier);
1557
+ }
1558
+ _assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
1559
+ const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
1560
+ const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
1561
+ const outputWidth = Math.ceil(width * safeMultiplier);
1562
+ const outputHeight = Math.ceil(height * safeMultiplier);
1563
+ const outputPixels = outputWidth * outputHeight;
1564
+ const configuredMaxPixels = Number(this.options.maxExportPixels);
1565
+ const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0 ? Math.floor(configuredMaxPixels) : 5e7;
1566
+ if (outputPixels > maxPixels) {
1567
+ throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
1568
+ }
1569
+ }
1338
1570
  async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1339
1571
  const imageElement = await this._createImageElement(dataUrl);
1340
1572
  const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
@@ -1379,6 +1611,7 @@ var ImageEditor = class {
1379
1611
  }
1380
1612
  _decodeBase64Payload(base64Payload) {
1381
1613
  const payload = String(base64Payload || "");
1614
+ if (!payload) throw new Error("Data URL base64 payload is empty");
1382
1615
  if (typeof atob === "function") {
1383
1616
  return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
1384
1617
  }
@@ -1387,6 +1620,13 @@ var ImageEditor = class {
1387
1620
  }
1388
1621
  throw new Error("Base64 decoding is unavailable");
1389
1622
  }
1623
+ _decodeDataUrlPayload(dataUrl) {
1624
+ const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
1625
+ if (!match || !match[2]) {
1626
+ throw new Error("Export produced an invalid or empty base64 data URL");
1627
+ }
1628
+ return this._decodeBase64Payload(match[2]);
1629
+ }
1390
1630
  /**
1391
1631
  * Gets the top-left corner coordinates of the given object.
1392
1632
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1496,24 +1736,49 @@ var ImageEditor = class {
1496
1736
  const currentHeight = this.canvas.getHeight();
1497
1737
  let requiredWidth = currentWidth;
1498
1738
  let requiredHeight = currentHeight;
1499
- fabricObjects.forEach((fabricObject) => {
1739
+ const layoutMode = this._getImageLayoutMode();
1740
+ const usesScrollableFitBounds = layoutMode === "fit" || layoutMode === "cover";
1741
+ let contentWidth = 0;
1742
+ let contentHeight = 0;
1743
+ const includeObjectBounds = (fabricObject, objectPadding = 0) => {
1500
1744
  if (!fabricObject) return;
1501
1745
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1502
1746
  const boundingRect = fabricObject.getBoundingRect(true, true);
1503
- requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1504
- requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1747
+ const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
1748
+ const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
1749
+ contentWidth = Math.max(contentWidth, right);
1750
+ contentHeight = Math.max(contentHeight, bottom);
1751
+ return { right, bottom };
1752
+ };
1753
+ fabricObjects.forEach((fabricObject) => {
1754
+ const bounds = includeObjectBounds(fabricObject, padding);
1755
+ if (!bounds) return;
1756
+ requiredWidth = Math.max(requiredWidth, bounds.right);
1757
+ requiredHeight = Math.max(requiredHeight, bounds.bottom);
1505
1758
  });
1506
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1759
+ if (usesScrollableFitBounds) {
1760
+ if (this.originalImage) includeObjectBounds(this.originalImage, 0);
1761
+ this.canvas.getObjects().forEach((object) => {
1762
+ if (object && object.maskId) includeObjectBounds(object, padding);
1763
+ });
1764
+ const contentSize = this._getScrollableCanvasSize(
1765
+ Math.max(1, contentWidth),
1766
+ Math.max(1, contentHeight)
1767
+ );
1768
+ const newWidth2 = contentSize.hasHorizontal ? Math.max(currentWidth, contentSize.width) : contentSize.width;
1769
+ const newHeight2 = contentSize.hasVertical ? Math.max(currentHeight, contentSize.height) : contentSize.height;
1770
+ if (newWidth2 !== currentWidth || newHeight2 !== currentHeight) {
1771
+ this._setCanvasSizeInt(newWidth2, newHeight2);
1772
+ }
1773
+ return;
1774
+ }
1507
1775
  let minWidth = 0;
1508
1776
  let minHeight = 0;
1509
- if (shouldUseScrollSafeViewport) {
1777
+ if (this.containerElement) {
1510
1778
  const viewport = this._getContainerViewportSize();
1511
1779
  const safetyMargin = this._getScrollSafetyMargin();
1512
1780
  minWidth = Math.max(1, viewport.width - safetyMargin);
1513
1781
  minHeight = Math.max(1, viewport.height - safetyMargin);
1514
- } else if (this.containerElement) {
1515
- minWidth = Math.floor(this.containerElement.clientWidth || 0);
1516
- minHeight = Math.floor(this.containerElement.clientHeight || 0);
1517
1782
  }
1518
1783
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1519
1784
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1524,16 +1789,60 @@ var ImageEditor = class {
1524
1789
  this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
1525
1790
  }
1526
1791
  }
1527
- /**
1528
- * Expands the canvas so one object remains visible after an edit.
1529
- *
1530
- * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1531
- * @param {number} [padding=10] - Extra canvas space after the object edge.
1532
- * @returns {void}
1533
- * @private
1534
- */
1535
- _expandCanvasToFitObject(fabricObject, padding = 10) {
1536
- this._expandCanvasToFitObjects([fabricObject], padding);
1792
+ _captureImageDisplayBounds() {
1793
+ if (!this.originalImage || !this.canvas) return null;
1794
+ this.originalImage.setCoords();
1795
+ const bounds = this.originalImage.getBoundingRect(true, true);
1796
+ const width = Number(bounds && bounds.width);
1797
+ const height = Number(bounds && bounds.height);
1798
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
1799
+ return {
1800
+ left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
1801
+ top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
1802
+ width,
1803
+ height
1804
+ };
1805
+ }
1806
+ _restoreImageDisplayBounds(displayBounds) {
1807
+ if (!displayBounds || !this.originalImage || !this.canvas) return;
1808
+ const imageWidth = Number(this.originalImage.width);
1809
+ const imageHeight = Number(this.originalImage.height);
1810
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
1811
+ const scaleX = Number(displayBounds.width) / imageWidth;
1812
+ const scaleY = Number(displayBounds.height) / imageHeight;
1813
+ if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
1814
+ const left = Number(displayBounds.left) || 0;
1815
+ const top = Number(displayBounds.top) || 0;
1816
+ const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
1817
+ const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
1818
+ const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
1819
+ const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
1820
+ const layoutMode = this._getImageLayoutMode();
1821
+ if (layoutMode === "fit" || layoutMode === "cover") {
1822
+ const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
1823
+ if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
1824
+ this._setCanvasSizeInt(contentSize.width, contentSize.height);
1825
+ }
1826
+ } else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
1827
+ this._setCanvasSizeInt(
1828
+ Math.max(currentCanvasWidth, requiredCanvasWidth),
1829
+ Math.max(currentCanvasHeight, requiredCanvasHeight)
1830
+ );
1831
+ }
1832
+ this.originalImage.set({
1833
+ originX: "left",
1834
+ originY: "top",
1835
+ left,
1836
+ top,
1837
+ scaleX,
1838
+ scaleY
1839
+ });
1840
+ this.originalImage.setCoords();
1841
+ this.baseImageScale = scaleX;
1842
+ this.currentScale = 1;
1843
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1844
+ this._updateInputs();
1845
+ this.canvas.renderAll();
1537
1846
  }
1538
1847
  /**
1539
1848
  * Scales the original image by a given factor, with animation.
@@ -1548,7 +1857,14 @@ var ImageEditor = class {
1548
1857
  } catch (error) {
1549
1858
  return Promise.reject(error);
1550
1859
  }
1551
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1860
+ return this.animationQueue.add(async () => {
1861
+ const operationToken = this._beginBusyOperation("scaleImage");
1862
+ try {
1863
+ await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
1864
+ } finally {
1865
+ this._endBusyOperation(operationToken);
1866
+ }
1867
+ }).finally(() => {
1552
1868
  if (!this._disposed && this.canvas) this._updateUI();
1553
1869
  });
1554
1870
  }
@@ -1582,10 +1898,16 @@ var ImageEditor = class {
1582
1898
  _assertEditorAvailable(operationName) {
1583
1899
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1584
1900
  }
1901
+ _isCropModeAllowedOperation(operationName) {
1902
+ return operationName === "applyCrop" || operationName === "cancelCrop";
1903
+ }
1585
1904
  _assertIdleForOperation(operationName, options = {}) {
1586
1905
  this._assertEditorAvailable(operationName);
1587
1906
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
1588
- if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1907
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1908
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1909
+ }
1910
+ if ((this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) && !isOwnInternalOperation) {
1589
1911
  throw new Error(`${operationName} cannot run while an animation is running`);
1590
1912
  }
1591
1913
  if (this._isLoading && !isOwnInternalOperation) {
@@ -1597,10 +1919,14 @@ var ImageEditor = class {
1597
1919
  }
1598
1920
  _assertCanQueueAnimation(operationName, options = {}) {
1599
1921
  this._assertEditorAvailable(operationName);
1600
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
1922
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1923
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1924
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1925
+ }
1926
+ if (this._isLoading && !isOwnInternalOperation) {
1601
1927
  throw new Error(`${operationName} cannot run while an image is loading`);
1602
1928
  }
1603
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1929
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1604
1930
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1605
1931
  }
1606
1932
  }
@@ -1694,7 +2020,7 @@ var ImageEditor = class {
1694
2020
  if (object.maskId) this._syncMaskLabel(object);
1695
2021
  });
1696
2022
  this._updateInputs();
1697
- if (saveHistory) this.saveState();
2023
+ if (saveHistory) this.saveState(options);
1698
2024
  } finally {
1699
2025
  if (didStartAnimation) {
1700
2026
  this.isAnimating = false;
@@ -1716,7 +2042,14 @@ var ImageEditor = class {
1716
2042
  } catch (error) {
1717
2043
  return Promise.reject(error);
1718
2044
  }
1719
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
2045
+ return this.animationQueue.add(async () => {
2046
+ const operationToken = this._beginBusyOperation("rotateImage");
2047
+ try {
2048
+ await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
2049
+ } finally {
2050
+ this._endBusyOperation(operationToken);
2051
+ }
2052
+ }).finally(() => {
1720
2053
  if (!this._disposed && this.canvas) this._updateUI();
1721
2054
  });
1722
2055
  }
@@ -1759,7 +2092,7 @@ var ImageEditor = class {
1759
2092
  if (object.maskId) this._syncMaskLabel(object);
1760
2093
  });
1761
2094
  this._updateInputs();
1762
- if (saveHistory) this.saveState();
2095
+ if (saveHistory) this.saveState(options);
1763
2096
  didCompleteRotation = true;
1764
2097
  } finally {
1765
2098
  if (!didCompleteRotation && !this._disposed && image) {
@@ -1786,11 +2119,23 @@ var ImageEditor = class {
1786
2119
  return Promise.reject(error);
1787
2120
  }
1788
2121
  return this.animationQueue.add(async () => {
2122
+ const operationToken = this._beginBusyOperation("resetImageTransform");
1789
2123
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1790
- await this._scaleImageImpl(1, { saveHistory: false });
1791
- await this._rotateImageImpl(0, { saveHistory: false });
1792
- const after = this._captureCanvasStateOrThrow("resetImageTransform");
1793
- this._pushStateTransition(before, after);
2124
+ try {
2125
+ await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2126
+ await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2127
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
2128
+ this._pushStateTransition(before, after);
2129
+ } catch (error) {
2130
+ try {
2131
+ await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
2132
+ } catch (restoreError) {
2133
+ this._reportError("resetImageTransform rollback failed", restoreError);
2134
+ }
2135
+ throw error;
2136
+ } finally {
2137
+ this._endBusyOperation(operationToken);
2138
+ }
1794
2139
  }).finally(() => {
1795
2140
  if (!this._disposed && this.canvas) this._updateUI();
1796
2141
  }).catch((error) => {
@@ -1814,8 +2159,13 @@ var ImageEditor = class {
1814
2159
  * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1815
2160
  * @public
1816
2161
  */
1817
- loadFromState(serializedState) {
2162
+ loadFromState(serializedState, options = {}) {
1818
2163
  if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2164
+ try {
2165
+ this._assertIdleForOperation("loadFromState", options);
2166
+ } catch (error) {
2167
+ return Promise.reject(error);
2168
+ }
1819
2169
  if (this._cropMode || this._cropRect) {
1820
2170
  this._removeCropRect();
1821
2171
  this._restoreCropObjectState();
@@ -1829,10 +2179,13 @@ var ImageEditor = class {
1829
2179
  try {
1830
2180
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1831
2181
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
2182
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
2183
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
2184
+ const hasRestoredCanvasSize = Number.isFinite(restoredCanvasWidth) && restoredCanvasWidth > 0 && Number.isFinite(restoredCanvasHeight) && restoredCanvasHeight > 0;
1832
2185
  if (editorMetadata && Object.prototype.hasOwnProperty.call(editorMetadata, "version") && Number(editorMetadata.version) !== 1) {
1833
2186
  this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
1834
2187
  }
1835
- this.canvas.loadFromJSON(state, async () => {
2188
+ const finishLoad = async () => {
1836
2189
  try {
1837
2190
  if (this._disposed || !this.canvas) {
1838
2191
  reject(new Error("Editor was disposed while loading state"));
@@ -1868,6 +2221,11 @@ var ImageEditor = class {
1868
2221
  this.currentScale = 1;
1869
2222
  this.currentRotation = 0;
1870
2223
  }
2224
+ if (hasRestoredCanvasSize) {
2225
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2226
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2227
+ this._updateCanvasSizeToImageBounds();
2228
+ }
1871
2229
  const masks = canvasObjects.filter((object) => object.maskId);
1872
2230
  masks.forEach((mask) => {
1873
2231
  this._restoreMaskControls(mask);
@@ -1895,6 +2253,9 @@ var ImageEditor = class {
1895
2253
  this._reportError("loadFromState() failed", callbackError);
1896
2254
  reject(callbackError);
1897
2255
  }
2256
+ };
2257
+ this.canvas.loadFromJSON(state, () => {
2258
+ void finishLoad();
1898
2259
  });
1899
2260
  } catch (error) {
1900
2261
  this._reportError("loadFromState() failed", error);
@@ -1961,22 +2322,29 @@ var ImageEditor = class {
1961
2322
  * @returns {void}
1962
2323
  * @public
1963
2324
  */
1964
- saveState() {
2325
+ saveState(options = {}) {
1965
2326
  if (!this.canvas) return;
2327
+ try {
2328
+ this._assertIdleForOperation("saveState", options);
2329
+ } catch (error) {
2330
+ this._reportError("saveState blocked", error);
2331
+ this._updateUI();
2332
+ return;
2333
+ }
1966
2334
  try {
1967
2335
  const after = this._captureCanvasStateOrThrow("saveState");
1968
2336
  const before = this._lastSnapshot || after;
1969
2337
  if (after === before) return;
1970
2338
  let executedOnce = false;
1971
2339
  const command = new Command(
1972
- () => {
2340
+ (commandOptions = {}) => {
1973
2341
  if (executedOnce) {
1974
- return this.loadFromState(after);
2342
+ return this.loadFromState(after, commandOptions);
1975
2343
  }
1976
2344
  executedOnce = true;
1977
2345
  return void 0;
1978
2346
  },
1979
- () => this.loadFromState(before)
2347
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
1980
2348
  );
1981
2349
  this.historyManager.execute(command);
1982
2350
  this._lastSnapshot = after;
@@ -2005,8 +2373,8 @@ var ImageEditor = class {
2005
2373
  if (before === after) return;
2006
2374
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
2007
2375
  const command = new Command(
2008
- () => this.loadFromState(after),
2009
- () => this.loadFromState(before)
2376
+ (commandOptions = {}) => this.loadFromState(after, commandOptions),
2377
+ (commandOptions = {}) => this.loadFromState(before, commandOptions)
2010
2378
  );
2011
2379
  this.historyManager.push(command);
2012
2380
  this._lastSnapshot = after;
@@ -2019,8 +2387,16 @@ var ImageEditor = class {
2019
2387
  * @public
2020
2388
  */
2021
2389
  undo() {
2022
- return this.historyManager.undo().then(() => {
2390
+ try {
2391
+ this._assertIdleForOperation("undo");
2392
+ } catch (error) {
2393
+ return Promise.reject(error);
2394
+ }
2395
+ const operationToken = this._beginBusyOperation("undo");
2396
+ return this.historyManager.undo(this._withInternalOperationOptions(operationToken)).then(() => {
2023
2397
  this._updateUI();
2398
+ }).finally(() => {
2399
+ this._endBusyOperation(operationToken);
2024
2400
  }).catch((error) => {
2025
2401
  this._reportError("undo failed", error);
2026
2402
  throw error;
@@ -2033,8 +2409,16 @@ var ImageEditor = class {
2033
2409
  * @public
2034
2410
  */
2035
2411
  redo() {
2036
- return this.historyManager.redo().then(() => {
2412
+ try {
2413
+ this._assertIdleForOperation("redo");
2414
+ } catch (error) {
2415
+ return Promise.reject(error);
2416
+ }
2417
+ const operationToken = this._beginBusyOperation("redo");
2418
+ return this.historyManager.redo(this._withInternalOperationOptions(operationToken)).then(() => {
2037
2419
  this._updateUI();
2420
+ }).finally(() => {
2421
+ this._endBusyOperation(operationToken);
2038
2422
  }).catch((error) => {
2039
2423
  this._reportError("redo failed", error);
2040
2424
  throw error;
@@ -2042,14 +2426,7 @@ var ImageEditor = class {
2042
2426
  }
2043
2427
  _rebindMaskEvents(mask) {
2044
2428
  if (!mask) return;
2045
- if (mask.__imageEditorMaskHandlers) {
2046
- try {
2047
- mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2048
- mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2049
- } catch (error) {
2050
- void error;
2051
- }
2052
- }
2429
+ this._cleanupMaskEvents(mask);
2053
2430
  const metadata = {};
2054
2431
  if (!Number.isFinite(Number(mask.originalAlpha))) {
2055
2432
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
@@ -2076,6 +2453,22 @@ var ImageEditor = class {
2076
2453
  mask.on("mouseout", mouseout);
2077
2454
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2078
2455
  }
2456
+ _cleanupMaskEvents(mask) {
2457
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2458
+ try {
2459
+ if (typeof mask.off === "function") {
2460
+ mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2461
+ mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2462
+ }
2463
+ } catch (error) {
2464
+ this._reportWarning("Mask event cleanup failed", error);
2465
+ }
2466
+ try {
2467
+ delete mask.__imageEditorMaskHandlers;
2468
+ } catch (error) {
2469
+ this._reportWarning("Mask event metadata cleanup failed", error);
2470
+ }
2471
+ }
2079
2472
  /**
2080
2473
  * Creates a mask and adds it to the canvas.
2081
2474
  *
@@ -2141,18 +2534,43 @@ var ImageEditor = class {
2141
2534
  }
2142
2535
  return value != null ? value : fallback;
2143
2536
  };
2144
- if (maskConfig.left === void 0 && this._lastMask) {
2145
- const previousMask = this._lastMask;
2146
- if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2147
- const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2148
- left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2149
- top = Math.round(previousBounds.top ?? firstOffset);
2150
- } else {
2151
- left = resolveValue(maskConfig.left, firstOffset, "width");
2152
- top = resolveValue(maskConfig.top, firstOffset, "height");
2537
+ const rejectInvalidMask = (message, error = null) => {
2538
+ this._reportWarning(`createMask: ${message}`, error);
2539
+ return null;
2540
+ };
2541
+ const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
2542
+ const resolvedValue = resolveValue(value, fallback, axis);
2543
+ const numericValue = Number(resolvedValue);
2544
+ if (!Number.isFinite(numericValue)) {
2545
+ throw new Error(`${fieldName} must be a finite number`);
2546
+ }
2547
+ if (constraints.positive && numericValue <= 0) {
2548
+ throw new Error(`${fieldName} must be greater than 0`);
2549
+ }
2550
+ if (constraints.nonNegative && numericValue < 0) {
2551
+ throw new Error(`${fieldName} must be 0 or greater`);
2552
+ }
2553
+ return numericValue;
2554
+ };
2555
+ try {
2556
+ maskConfig.gap = resolveNumber(maskConfig.gap, 5, "width", "gap", { nonNegative: true });
2557
+ maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, "width", "width", { positive: true });
2558
+ maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, "height", "height", { positive: true });
2559
+ maskConfig.angle = resolveNumber(maskConfig.angle, 0, "width", "angle");
2560
+ maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, "width", "alpha")));
2561
+ if (maskConfig.left === void 0 && this._lastMask) {
2562
+ const previousMask = this._lastMask;
2563
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2564
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2565
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2566
+ top = Math.round(previousBounds.top ?? firstOffset);
2567
+ } else {
2568
+ left = resolveNumber(maskConfig.left, firstOffset, "width", "left");
2569
+ top = resolveNumber(maskConfig.top, firstOffset, "height", "top");
2570
+ }
2571
+ } catch (error) {
2572
+ return rejectInvalidMask("invalid numeric configuration", error);
2153
2573
  }
2154
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2155
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
2156
2574
  maskConfig.left = left;
2157
2575
  maskConfig.top = top;
2158
2576
  let mask;
@@ -2161,10 +2579,15 @@ var ImageEditor = class {
2161
2579
  } else {
2162
2580
  switch (shapeType) {
2163
2581
  case "circle":
2582
+ try {
2583
+ maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min", "radius", { positive: true });
2584
+ } catch (error) {
2585
+ return rejectInvalidMask("invalid circle radius", error);
2586
+ }
2164
2587
  mask = new fabric.Circle({
2165
2588
  left,
2166
2589
  top,
2167
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
2590
+ radius: maskConfig.radius,
2168
2591
  fill: maskConfig.color,
2169
2592
  opacity: maskConfig.alpha,
2170
2593
  angle: maskConfig.angle,
@@ -2172,11 +2595,17 @@ var ImageEditor = class {
2172
2595
  });
2173
2596
  break;
2174
2597
  case "ellipse":
2598
+ try {
2599
+ maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, "width", "rx", { positive: true });
2600
+ maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, "height", "ry", { positive: true });
2601
+ } catch (error) {
2602
+ return rejectInvalidMask("invalid ellipse radius", error);
2603
+ }
2175
2604
  mask = new fabric.Ellipse({
2176
2605
  left,
2177
2606
  top,
2178
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
2179
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
2607
+ rx: maskConfig.rx,
2608
+ ry: maskConfig.ry,
2180
2609
  fill: maskConfig.color,
2181
2610
  opacity: maskConfig.alpha,
2182
2611
  angle: maskConfig.angle,
@@ -2185,8 +2614,20 @@ var ImageEditor = class {
2185
2614
  break;
2186
2615
  case "polygon": {
2187
2616
  let polygonPoints = maskConfig.points || [];
2188
- if (Array.isArray(polygonPoints) && polygonPoints.length) {
2189
- polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
2617
+ if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
2618
+ return rejectInvalidMask("polygon masks require at least three points");
2619
+ }
2620
+ try {
2621
+ polygonPoints = polygonPoints.map((point) => {
2622
+ const x = Number(Array.isArray(point) ? point[0] : point.x);
2623
+ const y = Number(Array.isArray(point) ? point[1] : point.y);
2624
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
2625
+ throw new Error("polygon point coordinates must be finite numbers");
2626
+ }
2627
+ return { x, y };
2628
+ });
2629
+ } catch (error) {
2630
+ return rejectInvalidMask("invalid polygon points", error);
2190
2631
  }
2191
2632
  mask = new fabric.Polygon(polygonPoints, {
2192
2633
  left,
@@ -2200,11 +2641,17 @@ var ImageEditor = class {
2200
2641
  }
2201
2642
  case "rect":
2202
2643
  default:
2644
+ try {
2645
+ if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, "width", "rx", { nonNegative: true });
2646
+ if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, "height", "ry", { nonNegative: true });
2647
+ } catch (error) {
2648
+ return rejectInvalidMask("invalid rectangle corner radius", error);
2649
+ }
2203
2650
  mask = new fabric.Rect({
2204
2651
  left,
2205
2652
  top,
2206
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
2207
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
2653
+ width: maskConfig.width,
2654
+ height: maskConfig.height,
2208
2655
  fill: maskConfig.color,
2209
2656
  opacity: maskConfig.alpha,
2210
2657
  angle: maskConfig.angle,
@@ -2242,10 +2689,10 @@ var ImageEditor = class {
2242
2689
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
2243
2690
  });
2244
2691
  this._rebindMaskEvents(mask);
2245
- this._expandCanvasToFitObject(mask);
2692
+ this._expandCanvasToFitObjects([mask]);
2246
2693
  this._lastMaskInitialLeft = left;
2247
2694
  this._lastMaskInitialTop = top;
2248
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
2695
+ this._lastMaskInitialWidth = maskConfig.width;
2249
2696
  const maskId = ++this.maskCounter;
2250
2697
  mask.set({
2251
2698
  maskId,
@@ -2286,6 +2733,7 @@ var ImageEditor = class {
2286
2733
  this.canvas.discardActiveObject();
2287
2734
  selectedMasks.forEach((mask) => {
2288
2735
  this._removeLabelForMask(mask);
2736
+ this._cleanupMaskEvents(mask);
2289
2737
  this.canvas.remove(mask);
2290
2738
  });
2291
2739
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -2310,7 +2758,10 @@ var ImageEditor = class {
2310
2758
  const saveHistory = options.saveHistory !== false;
2311
2759
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2312
2760
  masks.forEach((mask) => this._removeLabelForMask(mask));
2313
- masks.forEach((mask) => this.canvas.remove(mask));
2761
+ masks.forEach((mask) => {
2762
+ this._cleanupMaskEvents(mask);
2763
+ this.canvas.remove(mask);
2764
+ });
2314
2765
  this.canvas.discardActiveObject();
2315
2766
  this._lastMask = null;
2316
2767
  this._lastMaskInitialLeft = null;
@@ -2379,7 +2830,7 @@ var ImageEditor = class {
2379
2830
  if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2380
2831
  this._syncMaskLabel(backup.mask);
2381
2832
  } catch (error) {
2382
- void error;
2833
+ this._reportWarning("restoreMaskLabelBackups: failed to restore mask label", error);
2383
2834
  }
2384
2835
  });
2385
2836
  }
@@ -2510,7 +2961,6 @@ var ImageEditor = class {
2510
2961
  try {
2511
2962
  if (canvasObjectSet.has(label)) {
2512
2963
  this.canvas.remove(label);
2513
- canvasObjectSet.delete(label);
2514
2964
  }
2515
2965
  } catch (error) {
2516
2966
  void error;
@@ -2670,6 +3120,7 @@ var ImageEditor = class {
2670
3120
  this._assertIdleForOperation("mergeMasks");
2671
3121
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2672
3122
  if (!masks.length) return;
3123
+ const beforeImageDisplayBounds = this._captureImageDisplayBounds();
2673
3124
  const beforeJson = this._serializeCanvasState();
2674
3125
  const operationToken = this._beginBusyOperation("mergeMasks");
2675
3126
  this.canvas.discardActiveObject();
@@ -2688,12 +3139,13 @@ var ImageEditor = class {
2688
3139
  preserveScroll: true,
2689
3140
  resetMaskCounter: false
2690
3141
  }));
3142
+ this._restoreImageDisplayBounds(beforeImageDisplayBounds);
2691
3143
  const afterJson = this._serializeCanvasState();
2692
3144
  this._pushStateTransition(beforeJson, afterJson);
2693
3145
  } catch (error) {
2694
3146
  this._reportError("merge error", error);
2695
3147
  try {
2696
- await this.loadFromState(beforeJson);
3148
+ await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
2697
3149
  } catch (restoreError) {
2698
3150
  this._reportError("mergeMasks rollback failed", restoreError);
2699
3151
  }
@@ -2750,24 +3202,65 @@ var ImageEditor = class {
2750
3202
  */
2751
3203
  async exportImageBase64(options = {}) {
2752
3204
  if (!this.originalImage) throw new Error("No image loaded");
3205
+ options = options || {};
2753
3206
  this._assertIdleForOperation("exportImageBase64", options);
3207
+ const isNestedOperation = this._isOwnInternalOperation(options);
3208
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageBase64");
2754
3209
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2755
3210
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2756
3211
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
2757
3212
  const format = this._normalizeImageFormat(options.fileType || options.format);
2758
- if (!exportImageArea) {
2759
- const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
2760
- const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
2761
- const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
2762
- const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
2763
- const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
2764
- const activeObjectBackup2 = this._captureActiveObjectBackup();
3213
+ try {
3214
+ if (!exportImageArea) {
3215
+ const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
3216
+ const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
3217
+ const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
3218
+ const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
3219
+ const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
3220
+ const activeObjectBackup2 = this._captureActiveObjectBackup();
3221
+ try {
3222
+ masks2.forEach((mask) => {
3223
+ mask.set({ visible: false });
3224
+ });
3225
+ this.canvas.discardActiveObject();
3226
+ this.canvas.renderAll();
3227
+ this.originalImage.setCoords();
3228
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
3229
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
3230
+ return await this._exportCanvasRegionToDataURL({
3231
+ ...exportRegion,
3232
+ multiplier,
3233
+ quality,
3234
+ format,
3235
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
3236
+ });
3237
+ } finally {
3238
+ maskVisibilityBackups.forEach((backup) => {
3239
+ try {
3240
+ backup.object.set({ visible: backup.visible });
3241
+ } catch (error) {
3242
+ void error;
3243
+ }
3244
+ });
3245
+ this._restoreMaskExportBackups(maskStyleBackups2);
3246
+ this._restoreMaskLabelBackups(labelBackups2);
3247
+ this._restoreActiveObjectBackup(activeObjectBackup2);
3248
+ this.canvas.renderAll();
3249
+ }
3250
+ }
3251
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3252
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3253
+ const labelBackups = this._captureMaskLabelBackups(masks);
3254
+ const activeObjectBackup = this._captureActiveObjectBackup();
2765
3255
  try {
2766
- masks2.forEach((mask) => {
2767
- mask.set({ visible: false });
2768
- });
3256
+ masks.forEach((mask) => this._removeLabelForMask(mask));
2769
3257
  this.canvas.discardActiveObject();
2770
3258
  this.canvas.renderAll();
3259
+ masks.forEach((mask) => {
3260
+ mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
3261
+ mask.setCoords();
3262
+ });
3263
+ this.canvas.renderAll();
2771
3264
  this.originalImage.setCoords();
2772
3265
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2773
3266
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
@@ -2779,50 +3272,14 @@ var ImageEditor = class {
2779
3272
  sealPartialEdges: this._getPartialExportEdges(imageBounds)
2780
3273
  });
2781
3274
  } finally {
2782
- maskVisibilityBackups.forEach((backup) => {
2783
- try {
2784
- backup.object.set({ visible: backup.visible });
2785
- } catch (error) {
2786
- void error;
2787
- }
2788
- });
2789
- this._restoreMaskExportBackups(maskStyleBackups2);
2790
- this._restoreMaskLabelBackups(labelBackups2);
2791
- this._restoreActiveObjectBackup(activeObjectBackup2);
3275
+ this._restoreMaskExportBackups(maskStyleBackups);
3276
+ this._restoreMaskLabelBackups(labelBackups);
3277
+ this._restoreActiveObjectBackup(activeObjectBackup);
2792
3278
  this.canvas.renderAll();
2793
3279
  }
2794
- }
2795
- const masks = this.canvas.getObjects().filter((object) => object.maskId);
2796
- const maskStyleBackups = this._captureMaskExportBackups(masks);
2797
- const labelBackups = this._captureMaskLabelBackups(masks);
2798
- const activeObjectBackup = this._captureActiveObjectBackup();
2799
- let finalBase64;
2800
- try {
2801
- masks.forEach((mask) => this._removeLabelForMask(mask));
2802
- this.canvas.discardActiveObject();
2803
- this.canvas.renderAll();
2804
- masks.forEach((mask) => {
2805
- mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
2806
- mask.setCoords();
2807
- });
2808
- this.canvas.renderAll();
2809
- this.originalImage.setCoords();
2810
- const imageBounds = this.originalImage.getBoundingRect(true, true);
2811
- const exportRegion = this._getClampedCanvasRegion(imageBounds);
2812
- finalBase64 = await this._exportCanvasRegionToDataURL({
2813
- ...exportRegion,
2814
- multiplier,
2815
- quality,
2816
- format,
2817
- sealPartialEdges: this._getPartialExportEdges(imageBounds)
2818
- });
2819
3280
  } finally {
2820
- this._restoreMaskExportBackups(maskStyleBackups);
2821
- this._restoreMaskLabelBackups(labelBackups);
2822
- this._restoreActiveObjectBackup(activeObjectBackup);
2823
- this.canvas.renderAll();
3281
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
2824
3282
  }
2825
- return finalBase64;
2826
3283
  }
2827
3284
  /**
2828
3285
  * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
@@ -2854,7 +3311,10 @@ var ImageEditor = class {
2854
3311
  */
2855
3312
  async exportImageFile(options = {}) {
2856
3313
  if (!this.originalImage) throw new Error("No image loaded");
2857
- this._assertIdleForOperation("exportImageFile");
3314
+ options = options || {};
3315
+ this._assertIdleForOperation("exportImageFile", options);
3316
+ const isNestedOperation = this._isOwnInternalOperation(options);
3317
+ const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageFile");
2858
3318
  const {
2859
3319
  mergeMask = true,
2860
3320
  fileType = "jpeg",
@@ -2864,48 +3324,52 @@ var ImageEditor = class {
2864
3324
  } = options;
2865
3325
  const safeFileType = this._normalizeImageFormat(fileType);
2866
3326
  const normalizedQuality = this._normalizeQuality(quality);
2867
- let imageBase64;
2868
- if (mergeMask) {
2869
- imageBase64 = await this.exportImageBase64({
2870
- exportImageArea: true,
2871
- multiplier,
2872
- quality: normalizedQuality,
2873
- fileType: safeFileType
2874
- });
2875
- } else {
2876
- imageBase64 = await this.exportImageBase64({
2877
- exportImageArea: false,
2878
- multiplier,
2879
- quality: normalizedQuality,
2880
- fileType: safeFileType
2881
- });
2882
- }
2883
- let imageDataUrl = imageBase64;
2884
- if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
2885
- imageDataUrl = await new Promise((resolve, reject) => {
2886
- const imageElement = new window.Image();
2887
- imageElement.crossOrigin = "Anonymous";
2888
- imageElement.onload = () => {
2889
- try {
2890
- const offscreenCanvas = document.createElement("canvas");
2891
- offscreenCanvas.width = imageElement.width;
2892
- offscreenCanvas.height = imageElement.height;
2893
- const context = offscreenCanvas.getContext("2d");
2894
- if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2895
- context.drawImage(imageElement, 0, 0);
2896
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2897
- resolve(convertedDataUrl);
2898
- } catch (error) {
2899
- reject(error);
2900
- }
2901
- };
2902
- imageElement.onerror = reject;
2903
- imageElement.src = imageBase64;
2904
- });
3327
+ try {
3328
+ let imageBase64;
3329
+ if (mergeMask) {
3330
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3331
+ exportImageArea: true,
3332
+ multiplier,
3333
+ quality: normalizedQuality,
3334
+ fileType: safeFileType
3335
+ }));
3336
+ } else {
3337
+ imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3338
+ exportImageArea: false,
3339
+ multiplier,
3340
+ quality: normalizedQuality,
3341
+ fileType: safeFileType
3342
+ }));
3343
+ }
3344
+ let imageDataUrl = imageBase64;
3345
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
3346
+ imageDataUrl = await new Promise((resolve, reject) => {
3347
+ const imageElement = new window.Image();
3348
+ imageElement.crossOrigin = "Anonymous";
3349
+ imageElement.onload = () => {
3350
+ try {
3351
+ const offscreenCanvas = document.createElement("canvas");
3352
+ offscreenCanvas.width = imageElement.width;
3353
+ offscreenCanvas.height = imageElement.height;
3354
+ const context = offscreenCanvas.getContext("2d");
3355
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
3356
+ context.drawImage(imageElement, 0, 0);
3357
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
3358
+ resolve(convertedDataUrl);
3359
+ } catch (error) {
3360
+ reject(error);
3361
+ }
3362
+ };
3363
+ imageElement.onerror = reject;
3364
+ imageElement.src = imageBase64;
3365
+ });
3366
+ }
3367
+ const bytes = this._decodeDataUrlPayload(imageDataUrl);
3368
+ const mime = `image/${safeFileType}`;
3369
+ return new File([bytes], fileName, { type: mime });
3370
+ } finally {
3371
+ if (!isNestedOperation) this._endBusyOperation(operationToken);
2905
3372
  }
2906
- const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
2907
- const mime = `image/${safeFileType}`;
2908
- return new File([bytes], fileName, { type: mime });
2909
3373
  }
2910
3374
  _clearMaskPlacementMemory() {
2911
3375
  this._lastMask = null;
@@ -2913,7 +3377,7 @@ var ImageEditor = class {
2913
3377
  this._lastMaskInitialTop = null;
2914
3378
  this._lastMaskInitialWidth = null;
2915
3379
  }
2916
- async _restoreStateAfterCropFailure(beforeJson, message, error) {
3380
+ async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
2917
3381
  this._reportError(message, error);
2918
3382
  if (this._cropRect && this.canvas) this._removeCropRect();
2919
3383
  this._cropRect = null;
@@ -2924,7 +3388,7 @@ var ImageEditor = class {
2924
3388
  this._prevSelectionSetting = void 0;
2925
3389
  if (beforeJson) {
2926
3390
  try {
2927
- await this.loadFromState(beforeJson);
3391
+ await this.loadFromState(beforeJson, options);
2928
3392
  } catch (restoreError) {
2929
3393
  this._reportError("applyCrop: rollback failed", restoreError);
2930
3394
  }
@@ -2949,28 +3413,38 @@ var ImageEditor = class {
2949
3413
  this._cropPrevEvented = null;
2950
3414
  }
2951
3415
  _removeCropRect() {
2952
- if (!this._cropRect) return;
2953
- try {
2954
- if (this._cropHandlers && this._cropHandlers.length) {
2955
- this._cropHandlers.forEach((targetHandlers) => {
2956
- targetHandlers.handlers.forEach((handlerRecord) => {
3416
+ if (this._cropHandlers && this._cropHandlers.length) {
3417
+ this._cropHandlers.forEach((targetHandlers) => {
3418
+ (targetHandlers.handlers || []).forEach((handlerRecord) => {
3419
+ try {
2957
3420
  if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2958
3421
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2959
3422
  }
2960
- });
3423
+ } catch (error) {
3424
+ this._reportWarning("Crop handler cleanup failed", error);
3425
+ }
2961
3426
  });
2962
- }
2963
- } catch (error) {
2964
- void error;
3427
+ });
2965
3428
  }
2966
3429
  try {
2967
- if (this.canvas) this.canvas.remove(this._cropRect);
3430
+ if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect);
2968
3431
  } catch (error) {
2969
3432
  void error;
2970
3433
  }
2971
3434
  this._cropRect = null;
2972
3435
  this._cropHandlers = [];
2973
3436
  }
3437
+ _getCropRectContentBounds(cropRect) {
3438
+ if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
3439
+ const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
3440
+ const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
3441
+ return {
3442
+ left: Number(cropRect.left) || 0,
3443
+ top: Number(cropRect.top) || 0,
3444
+ width,
3445
+ height
3446
+ };
3447
+ }
2974
3448
  /**
2975
3449
  * Enters crop mode by creating a resizable crop rectangle above the base image.
2976
3450
  *
@@ -2994,14 +3468,19 @@ var ImageEditor = class {
2994
3468
  const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
2995
3469
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
2996
3470
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
2997
- const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2998
- const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
3471
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
3472
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
2999
3473
  const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
3000
3474
  const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
3001
3475
  const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
3002
3476
  const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
3003
3477
  const width = minCropWidth;
3004
3478
  const height = minCropHeight;
3479
+ const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
3480
+ if (requestedCropRotation && !this._cropRotationWarningEmitted) {
3481
+ this._cropRotationWarningEmitted = true;
3482
+ this._reportWarning("crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported");
3483
+ }
3005
3484
  const cropRect = new fabric.Rect({
3006
3485
  left,
3007
3486
  top,
@@ -3013,8 +3492,8 @@ var ImageEditor = class {
3013
3492
  strokeWidth: 1,
3014
3493
  strokeUniform: true,
3015
3494
  selectable: true,
3016
- hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
3017
- lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
3495
+ hasRotatingPoint: false,
3496
+ lockRotation: true,
3018
3497
  cornerSize: 8,
3019
3498
  objectCaching: false,
3020
3499
  originX: "left",
@@ -3051,7 +3530,7 @@ var ImageEditor = class {
3051
3530
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3052
3531
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3053
3532
  cropRect.setCoords();
3054
- const cropBounds = cropRect.getBoundingRect(true, true);
3533
+ const cropBounds = this._getCropRectContentBounds(cropRect);
3055
3534
  const imageLeft = Number(imageBounds.left) || 0;
3056
3535
  const imageTop = Number(imageBounds.top) || 0;
3057
3536
  const imageRight = imageLeft + (Number(imageBounds.width) || 0);
@@ -3125,89 +3604,100 @@ var ImageEditor = class {
3125
3604
  async applyCrop() {
3126
3605
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3127
3606
  this._assertIdleForOperation("applyCrop");
3128
- this._cropRect.setCoords();
3129
- const rectBounds = this._cropRect.getBoundingRect(true, true);
3130
- const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3131
- const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
3132
- this._restoreCropObjectState();
3133
- let beforeJson;
3134
- try {
3135
- beforeJson = this._serializeCanvasState();
3136
- } catch (error) {
3137
- this._reportWarning("applyCrop: could not serialize before state", error);
3138
- beforeJson = null;
3139
- }
3140
- const preservedMasks = [];
3607
+ const operationToken = this._beginBusyOperation("applyCrop");
3608
+ const internalOptions = this._withInternalOperationOptions(operationToken);
3141
3609
  try {
3142
- const masks = this.canvas.getObjects().filter((object) => object.maskId);
3143
- if (masks && masks.length) {
3144
- masks.forEach((mask) => {
3145
- mask.setCoords();
3146
- const maskBounds = mask.getBoundingRect(true, true);
3147
- 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;
3148
- this._removeLabelForMask(mask);
3149
- this.canvas.remove(mask);
3150
- if (shouldPreserveMasks && intersectsCrop) {
3151
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3152
- mask.set({ visible: true });
3153
- preservedMasks.push(mask);
3154
- }
3155
- });
3156
- this._clearMaskPlacementMemory();
3157
- this.canvas.discardActiveObject();
3158
- this.canvas.renderAll();
3610
+ this._cropRect.setCoords();
3611
+ const rectBounds = this._getCropRectContentBounds(this._cropRect);
3612
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
3613
+ const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
3614
+ this._restoreCropObjectState();
3615
+ let beforeJson;
3616
+ try {
3617
+ beforeJson = this._serializeCanvasState();
3618
+ } catch (error) {
3619
+ this._reportError("applyCrop: failed to capture rollback state", error);
3620
+ beforeJson = null;
3159
3621
  }
3160
- } catch (error) {
3161
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error);
3162
- return;
3163
- }
3164
- this._removeCropRect();
3165
- this._cropMode = false;
3166
- this.canvas.selection = !!this._prevSelectionSetting;
3167
- this._prevSelectionSetting = void 0;
3168
- let croppedBase64;
3169
- try {
3170
- croppedBase64 = await this._exportCanvasRegionToDataURL({
3171
- ...cropRegion,
3172
- multiplier: 1,
3173
- quality: this._normalizeQuality(this.options.downsampleQuality),
3174
- format: "jpeg"
3175
- });
3176
- } catch (error) {
3177
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error);
3178
- return;
3179
- }
3180
- try {
3181
- await this.loadImage(croppedBase64, { resetMaskCounter: false });
3182
- if (preservedMasks.length) {
3183
- preservedMasks.forEach((mask) => {
3184
- this._rebindMaskEvents(mask);
3185
- this.canvas.add(mask);
3186
- this.canvas.bringToFront(mask);
3622
+ if (!beforeJson) {
3623
+ this.cancelCrop();
3624
+ return;
3625
+ }
3626
+ const preservedMasks = [];
3627
+ try {
3628
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
3629
+ if (masks && masks.length) {
3630
+ masks.forEach((mask) => {
3631
+ mask.setCoords();
3632
+ const maskBounds = mask.getBoundingRect(true, true);
3633
+ const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3634
+ this._removeLabelForMask(mask);
3635
+ this._cleanupMaskEvents(mask);
3636
+ this.canvas.remove(mask);
3637
+ if (shouldPreserveMasks && intersectsCrop) {
3638
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3639
+ mask.set({ visible: true });
3640
+ preservedMasks.push(mask);
3641
+ }
3642
+ });
3643
+ this._clearMaskPlacementMemory();
3644
+ this.canvas.discardActiveObject();
3645
+ this.canvas.renderAll();
3646
+ }
3647
+ } catch (error) {
3648
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error, internalOptions);
3649
+ return;
3650
+ }
3651
+ this._removeCropRect();
3652
+ this._cropMode = false;
3653
+ this.canvas.selection = !!this._prevSelectionSetting;
3654
+ this._prevSelectionSetting = void 0;
3655
+ let croppedBase64;
3656
+ try {
3657
+ croppedBase64 = await this._exportCanvasRegionToDataURL({
3658
+ ...cropRegion,
3659
+ multiplier: 1,
3660
+ quality: this._normalizeQuality(this.options.downsampleQuality),
3661
+ format: "jpeg"
3187
3662
  });
3188
- this._lastMask = preservedMasks[preservedMasks.length - 1];
3189
- this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
3190
- this._updateMaskList();
3191
- this.canvas.renderAll();
3663
+ } catch (error) {
3664
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error, internalOptions);
3665
+ return;
3192
3666
  }
3193
- } catch (error) {
3194
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
3195
- return;
3196
- }
3197
- let afterJson;
3198
- try {
3199
- afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
3200
- } catch (error) {
3201
- this._reportWarning("applyCrop: failed to serialize after state", error);
3202
- afterJson = null;
3203
- }
3204
- try {
3205
- this._pushStateTransition(beforeJson, afterJson);
3206
- } catch (error) {
3207
- this._reportWarning("applyCrop: failed to push history command", error);
3667
+ try {
3668
+ await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
3669
+ if (preservedMasks.length) {
3670
+ preservedMasks.forEach((mask) => {
3671
+ this._rebindMaskEvents(mask);
3672
+ this.canvas.add(mask);
3673
+ this.canvas.bringToFront(mask);
3674
+ });
3675
+ this._lastMask = preservedMasks[preservedMasks.length - 1];
3676
+ this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
3677
+ this._updateMaskList();
3678
+ this.canvas.renderAll();
3679
+ }
3680
+ } catch (error) {
3681
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error, internalOptions);
3682
+ return;
3683
+ }
3684
+ let afterJson;
3685
+ try {
3686
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
3687
+ } catch (error) {
3688
+ this._reportWarning("applyCrop: failed to serialize after state", error);
3689
+ afterJson = null;
3690
+ }
3691
+ try {
3692
+ this._pushStateTransition(beforeJson, afterJson);
3693
+ } catch (error) {
3694
+ this._reportWarning("applyCrop: failed to push history command", error);
3695
+ }
3696
+ this._updateUI();
3697
+ this.canvas.renderAll();
3698
+ } finally {
3699
+ this._endBusyOperation(operationToken);
3208
3700
  }
3209
- this._updateUI();
3210
- this.canvas.renderAll();
3211
3701
  }
3212
3702
  /* ---------- Misc / UI ---------- */
3213
3703
  /**
@@ -3216,7 +3706,7 @@ var ImageEditor = class {
3216
3706
  * @private
3217
3707
  */
3218
3708
  _updateInputs() {
3219
- const scaleInputElement = this._getElement("scaleRate");
3709
+ const scaleInputElement = this._getElement("scalePercentageInput");
3220
3710
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3221
3711
  }
3222
3712
  /**
@@ -3240,7 +3730,7 @@ var ImageEditor = class {
3240
3730
  for (const key of Object.keys(this.elements || {})) {
3241
3731
  const element = this._getElement(key);
3242
3732
  if (!element) continue;
3243
- if (key === "applyCropBtn" || key === "cancelCropBtn") {
3733
+ if (key === "applyCropButton" || key === "cancelCropButton" || key === "applyCropBtn" || key === "cancelCropBtn") {
3244
3734
  this._setDisabled(key, false);
3245
3735
  } else {
3246
3736
  this._setDisabled(key, true);
@@ -3248,24 +3738,24 @@ var ImageEditor = class {
3248
3738
  }
3249
3739
  return;
3250
3740
  }
3251
- this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3252
- this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3253
- this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3254
- this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3255
- this._setDisabled("addMaskBtn", !hasImage || isBusy);
3256
- this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3257
- this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3258
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3259
- this._setDisabled("downloadBtn", !hasImage || isBusy);
3260
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3261
- this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3262
- this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3263
- this._setDisabled("cropBtn", !hasImage || isBusy);
3264
- this._setDisabled("applyCropBtn", true);
3265
- this._setDisabled("cancelCropBtn", true);
3266
- this._setDisabled("scaleRate", !hasImage || isBusy);
3267
- this._setDisabled("rotationLeftInput", !hasImage || isBusy);
3268
- this._setDisabled("rotationRightInput", !hasImage || isBusy);
3741
+ this._setDisabled("zoomInButton", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3742
+ this._setDisabled("zoomOutButton", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3743
+ this._setDisabled("rotateLeftButton", !hasImage || isBusy);
3744
+ this._setDisabled("rotateRightButton", !hasImage || isBusy);
3745
+ this._setDisabled("createMaskButton", !hasImage || isBusy);
3746
+ this._setDisabled("removeSelectedMaskButton", !hasSelectedMask || isBusy);
3747
+ this._setDisabled("removeAllMasksButton", !hasMasks || isBusy);
3748
+ this._setDisabled("mergeMasksButton", !hasImage || !hasMasks || isBusy);
3749
+ this._setDisabled("downloadImageButton", !hasImage || isBusy);
3750
+ this._setDisabled("resetImageTransformButton", !hasImage || isDefaultTransform || isBusy);
3751
+ this._setDisabled("undoButton", !hasImage || isBusy || !canUndo);
3752
+ this._setDisabled("redoButton", !hasImage || isBusy || !canRedo);
3753
+ this._setDisabled("enterCropModeButton", !hasImage || isBusy);
3754
+ this._setDisabled("applyCropButton", true);
3755
+ this._setDisabled("cancelCropButton", true);
3756
+ this._setDisabled("scalePercentageInput", !hasImage || isBusy);
3757
+ this._setDisabled("rotateLeftDegreesInput", !hasImage || isBusy);
3758
+ this._setDisabled("rotateRightDegreesInput", !hasImage || isBusy);
3269
3759
  this._setDisabled("maskList", !hasImage || isBusy);
3270
3760
  this._setDisabled("imageInput", isBusy);
3271
3761
  this._setDisabled("uploadArea", isBusy);
@@ -3273,7 +3763,7 @@ var ImageEditor = class {
3273
3763
  /**
3274
3764
  * Enables or disables a specific UI element (typically a button) by its key.
3275
3765
  *
3276
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
3766
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3277
3767
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3278
3768
  * @private
3279
3769
  */
@@ -3397,14 +3887,7 @@ var ImageEditor = class {
3397
3887
  } catch (error) {
3398
3888
  void error;
3399
3889
  }
3400
- if (this._cropRect) {
3401
- try {
3402
- this.canvas.remove(this._cropRect);
3403
- } catch (error) {
3404
- void error;
3405
- }
3406
- this._cropRect = null;
3407
- }
3890
+ if (this._cropRect) this._removeCropRect();
3408
3891
  if (this.containerElement && this._containerOriginalOverflow) {
3409
3892
  try {
3410
3893
  this._restoreContainerOverflowState();
@@ -3427,11 +3910,19 @@ var ImageEditor = class {
3427
3910
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3428
3911
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3429
3912
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3913
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3430
3914
  } catch (error) {
3431
3915
  void error;
3432
3916
  }
3433
3917
  }
3434
3918
  if (this.canvas) {
3919
+ try {
3920
+ this.canvas.getObjects().forEach((object) => {
3921
+ if (object && object.maskId) this._cleanupMaskEvents(object);
3922
+ });
3923
+ } catch (error) {
3924
+ void error;
3925
+ }
3435
3926
  try {
3436
3927
  this.canvas.dispose();
3437
3928
  } catch (error) {
@@ -3528,7 +4019,7 @@ var AnimationQueue = class {
3528
4019
  task.reject(error);
3529
4020
  }
3530
4021
  } finally {
3531
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
4022
+ if (this.currentTask === task) this.currentTask = null;
3532
4023
  }
3533
4024
  }
3534
4025
  } finally {
@@ -3626,11 +4117,11 @@ var HistoryManager = class {
3626
4117
  *
3627
4118
  * @returns {Promise<void>} Resolves after the undo task completes.
3628
4119
  */
3629
- undo() {
4120
+ undo(options = {}) {
3630
4121
  return this.enqueue(async () => {
3631
4122
  if (this.currentIndex >= 0) {
3632
4123
  const index = this.currentIndex;
3633
- await this.history[index].undo();
4124
+ await this.history[index].undo(options);
3634
4125
  this.currentIndex = index - 1;
3635
4126
  }
3636
4127
  });
@@ -3640,11 +4131,11 @@ var HistoryManager = class {
3640
4131
  *
3641
4132
  * @returns {Promise<void>} Resolves after the redo task completes.
3642
4133
  */
3643
- redo() {
4134
+ redo(options = {}) {
3644
4135
  return this.enqueue(async () => {
3645
4136
  if (this.currentIndex < this.history.length - 1) {
3646
4137
  const index = this.currentIndex + 1;
3647
- await this.history[index].execute();
4138
+ await this.history[index].execute(options);
3648
4139
  this.currentIndex = index;
3649
4140
  }
3650
4141
  });