@bensitu/image-editor 1.4.1 → 1.5.0

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,13 +5,13 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.4.1
8
+ * @version 1.5.0
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
12
12
  */
13
13
  var fabric = null;
14
- var INTERNAL_OPERATION_TOKEN = /* @__PURE__ */ Symbol("ImageEditorInternalOperation");
14
+ var INTERNAL_OPERATION_TOKEN = /* @__PURE__ */ Symbol.for("ImageEditorInternalOperation");
15
15
  function getGlobalScope() {
16
16
  if (typeof globalThis !== "undefined") return globalThis;
17
17
  if (typeof self !== "undefined") return self;
@@ -146,7 +146,7 @@ var ImageEditor = class {
146
146
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
147
  this._disposed = false;
148
148
  this._initialized = false;
149
- this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
149
+ this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
150
150
  this.animationQueue = new AnimationQueue();
151
151
  this.historyManager = new HistoryManager(this.maxHistorySize);
152
152
  }
@@ -192,10 +192,12 @@ var ImageEditor = class {
192
192
  * Use this method to set up the editor UI before interacting with it.
193
193
  *
194
194
  * @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.
195
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
196
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
197
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
198
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
199
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
200
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
199
201
  *
200
202
  * @returns {void}
201
203
  *
@@ -204,7 +206,7 @@ var ImageEditor = class {
204
206
  * @example
205
207
  * editor.init({
206
208
  * canvas: 'myFabricCanvasId',
207
- * downloadBtn: 'myDownloadButtonId'
209
+ * downloadImageButton: 'myDownloadButtonId'
208
210
  * });
209
211
  */
210
212
  init(idMap = {}) {
@@ -223,33 +225,53 @@ var ImageEditor = class {
223
225
  this._containerOriginalOverflow = null;
224
226
  this._lastContainerViewportSize = null;
225
227
  this._canvasElementOriginalStyle = null;
228
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
226
229
  const defaults = {
227
230
  canvas: "fabricCanvas",
228
231
  canvasContainer: null,
229
232
  // 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",
233
+ imagePlaceholder: "imagePlaceholder",
234
+ imgPlaceholder: null,
235
+ scalePercentageInput: "scalePercentageInput",
236
+ scaleRate: null,
237
+ rotateLeftDegreesInput: "rotateLeftDegreesInput",
238
+ rotationLeftInput: null,
239
+ rotateRightDegreesInput: "rotateRightDegreesInput",
240
+ rotationRightInput: null,
241
+ rotateLeftButton: "rotateLeftButton",
242
+ rotateLeftBtn: null,
243
+ rotateRightButton: "rotateRightButton",
244
+ rotateRightBtn: null,
245
+ createMaskButton: "createMaskButton",
246
+ addMaskBtn: null,
247
+ removeSelectedMaskButton: "removeSelectedMaskButton",
248
+ removeMaskBtn: null,
249
+ removeAllMasksButton: "removeAllMasksButton",
250
+ removeAllMasksBtn: null,
251
+ mergeMasksButton: "mergeMasksButton",
252
+ mergeBtn: null,
253
+ downloadImageButton: "downloadImageButton",
254
+ downloadBtn: null,
241
255
  maskList: "maskList",
242
- zoomInBtn: "zoomInBtn",
243
- zoomOutBtn: "zoomOutBtn",
244
- resetBtn: "resetBtn",
245
- undoBtn: "undoBtn",
246
- redoBtn: "redoBtn",
256
+ zoomInButton: "zoomInButton",
257
+ zoomInBtn: null,
258
+ zoomOutButton: "zoomOutButton",
259
+ zoomOutBtn: null,
260
+ resetImageTransformButton: "resetImageTransformButton",
261
+ resetBtn: null,
262
+ undoButton: "undoButton",
263
+ undoBtn: null,
264
+ redoButton: "redoButton",
265
+ redoBtn: null,
247
266
  imageInput: "imageInput",
248
- cropBtn: "cropBtn",
249
- applyCropBtn: "applyCropBtn",
250
- cancelCropBtn: "cancelCropBtn"
267
+ enterCropModeButton: "enterCropModeButton",
268
+ cropBtn: null,
269
+ applyCropButton: "applyCropButton",
270
+ applyCropBtn: null,
271
+ cancelCropButton: "cancelCropButton",
272
+ cancelCropBtn: null
251
273
  };
252
- this.elements = { ...defaults, ...idMap };
274
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
253
275
  this._elementCache = {};
254
276
  this._initCanvas();
255
277
  this._bindEvents();
@@ -262,6 +284,63 @@ var ImageEditor = class {
262
284
  this._updatePlaceholderStatus();
263
285
  }
264
286
  }
287
+ _resolveElementIdMap(idMap, defaults) {
288
+ const resolved = { ...defaults, ...idMap };
289
+ this._resolveElementAliases(resolved, idMap, defaults, "imagePlaceholder", ["imgPlaceholder"]);
290
+ this._resolveElementAliases(resolved, idMap, defaults, "scalePercentageInput", ["scaleRate"]);
291
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateLeftDegreesInput", ["rotationLeftInput"]);
292
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateRightDegreesInput", ["rotationRightInput"]);
293
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateLeftButton", "rotateLeftBtn");
294
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateRightButton", "rotateRightBtn");
295
+ this._resolveElementAlias(resolved, idMap, defaults, "createMaskButton", "addMaskBtn");
296
+ this._resolveElementAliases(resolved, idMap, defaults, "removeSelectedMaskButton", ["removeMaskBtn"]);
297
+ this._resolveElementAlias(resolved, idMap, defaults, "removeAllMasksButton", "removeAllMasksBtn");
298
+ this._resolveElementAlias(resolved, idMap, defaults, "mergeMasksButton", "mergeBtn");
299
+ this._resolveElementAliases(resolved, idMap, defaults, "downloadImageButton", ["downloadBtn"]);
300
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomInButton", "zoomInBtn");
301
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomOutButton", "zoomOutBtn");
302
+ this._resolveElementAlias(resolved, idMap, defaults, "resetImageTransformButton", "resetBtn");
303
+ this._resolveElementAlias(resolved, idMap, defaults, "undoButton", "undoBtn");
304
+ this._resolveElementAlias(resolved, idMap, defaults, "redoButton", "redoBtn");
305
+ this._resolveElementAliases(resolved, idMap, defaults, "enterCropModeButton", ["cropBtn"]);
306
+ this._resolveElementAlias(resolved, idMap, defaults, "applyCropButton", "applyCropBtn");
307
+ this._resolveElementAlias(resolved, idMap, defaults, "cancelCropButton", "cancelCropBtn");
308
+ return resolved;
309
+ }
310
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
311
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
312
+ }
313
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
314
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
315
+ if (hasCanonicalKey) {
316
+ resolved[canonicalKey] = idMap[canonicalKey];
317
+ return;
318
+ }
319
+ let deprecatedValue;
320
+ let hasDeprecatedValue = false;
321
+ for (const deprecatedKey of deprecatedKeys) {
322
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
323
+ if (!hasDeprecatedValue) {
324
+ deprecatedValue = idMap[deprecatedKey];
325
+ hasDeprecatedValue = true;
326
+ }
327
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
328
+ }
329
+ }
330
+ if (hasDeprecatedValue) {
331
+ resolved[canonicalKey] = deprecatedValue;
332
+ return;
333
+ }
334
+ resolved[canonicalKey] = defaults[canonicalKey];
335
+ }
336
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
337
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
338
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
339
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
340
+ this._reportWarning(
341
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
342
+ );
343
+ }
265
344
  _reportError(message, error = null) {
266
345
  const handler = this.options && this.options.onError;
267
346
  if (typeof handler !== "function") return;
@@ -278,6 +357,11 @@ var ImageEditor = class {
278
357
  } catch {
279
358
  }
280
359
  }
360
+ _notifyImageLoaded() {
361
+ const optionsCallback = this.options && this.options.onImageLoaded;
362
+ const callback = typeof optionsCallback === "function" ? optionsCallback : this.onImageLoaded;
363
+ if (typeof callback === "function") callback();
364
+ }
281
365
  /**
282
366
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
283
367
  *
@@ -300,7 +384,7 @@ var ImageEditor = class {
300
384
  } else {
301
385
  this.containerElement = canvasElement.parentElement;
302
386
  }
303
- this.placeholderElement = this._getElement("imgPlaceholder") || null;
387
+ this.placeholderElement = this._getElement("imagePlaceholder") || null;
304
388
  let initialWidth = this.options.canvasWidth;
305
389
  let initialHeight = this.options.canvasHeight;
306
390
  if (this.containerElement) {
@@ -450,20 +534,20 @@ var ImageEditor = class {
450
534
  });
451
535
  }
452
536
  });
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", () => {
537
+ this._bindIfExists("zoomInButton", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
538
+ this._bindIfExists("zoomOutButton", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
539
+ this._bindIfExists("resetImageTransformButton", "click", () => {
456
540
  this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
457
541
  });
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");
542
+ this._bindIfExists("createMaskButton", "click", () => this.createMask());
543
+ this._bindIfExists("removeSelectedMaskButton", "click", () => this.removeSelectedMask());
544
+ this._bindIfExists("removeAllMasksButton", "click", () => this.removeAllMasks());
545
+ this._bindIfExists("mergeMasksButton", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
546
+ this._bindIfExists("downloadImageButton", "click", () => this.downloadImage());
547
+ this._bindIfExists("undoButton", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
548
+ this._bindIfExists("redoButton", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
549
+ this._bindIfExists("rotateLeftButton", "click", () => {
550
+ const rotationInputElement = this._getElement("rotateLeftDegreesInput");
467
551
  let step = this.options.rotationStep;
468
552
  if (rotationInputElement) {
469
553
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -471,8 +555,8 @@ var ImageEditor = class {
471
555
  }
472
556
  this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
473
557
  });
474
- this._bindIfExists("rotateRightBtn", "click", () => {
475
- const rotationInputElement = this._getElement("rotationRightInput");
558
+ this._bindIfExists("rotateRightButton", "click", () => {
559
+ const rotationInputElement = this._getElement("rotateRightDegreesInput");
476
560
  let step = this.options.rotationStep;
477
561
  if (rotationInputElement) {
478
562
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -480,11 +564,11 @@ var ImageEditor = class {
480
564
  }
481
565
  this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
482
566
  });
483
- this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
484
- this._bindIfExists("applyCropBtn", "click", () => {
567
+ this._bindIfExists("enterCropModeButton", "click", () => this.enterCropMode());
568
+ this._bindIfExists("applyCropButton", "click", () => {
485
569
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
486
570
  });
487
- this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
571
+ this._bindIfExists("cancelCropButton", "click", () => this.cancelCrop());
488
572
  this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
489
573
  }
490
574
  /**
@@ -575,12 +659,14 @@ var ImageEditor = class {
575
659
  const imageElement = await this._createImageElement(imageBase64);
576
660
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
577
661
  let loadSource = imageBase64;
578
- if (this.options.downsampleOnLoad) {
579
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
662
+ const downsampleMaxWidth = Number(this.options.downsampleMaxWidth);
663
+ const downsampleMaxHeight = Number(this.options.downsampleMaxHeight);
664
+ if (this.options.downsampleOnLoad && downsampleMaxWidth > 0 && downsampleMaxHeight > 0) {
665
+ const shouldResize = imageElement.naturalWidth > downsampleMaxWidth || imageElement.naturalHeight > downsampleMaxHeight;
580
666
  if (shouldResize) {
581
667
  const ratio = Math.min(
582
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
583
- this.options.downsampleMaxHeight / imageElement.naturalHeight
668
+ downsampleMaxWidth / imageElement.naturalWidth,
669
+ downsampleMaxHeight / imageElement.naturalHeight
584
670
  );
585
671
  const targetWidth = Math.round(imageElement.naturalWidth * ratio);
586
672
  const targetHeight = Math.round(imageElement.naturalHeight * ratio);
@@ -592,6 +678,8 @@ var ImageEditor = class {
592
678
  imageBase64
593
679
  );
594
680
  }
681
+ } else if (this.options.downsampleOnLoad) {
682
+ this._reportWarning("loadImage: downsample limits must be positive numbers; using the original image");
595
683
  }
596
684
  const fabricImage = await this._createFabricImageFromURL(loadSource);
597
685
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
@@ -650,9 +738,7 @@ var ImageEditor = class {
650
738
  this._updateUI();
651
739
  this.canvas.renderAll();
652
740
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
653
- if (typeof this.onImageLoaded === "function") {
654
- this.onImageLoaded();
655
- }
741
+ this._notifyImageLoaded();
656
742
  } catch (error) {
657
743
  await this._rollbackLoadImageTransaction(transaction);
658
744
  throw error;
@@ -669,6 +755,15 @@ var ImageEditor = class {
669
755
  const fabricInstance2 = ensureFabric();
670
756
  return !!(this.originalImage && fabricInstance2 && this.originalImage instanceof fabricInstance2.Image && this.originalImage.width > 0 && this.originalImage.height > 0);
671
757
  }
758
+ /**
759
+ * Checks whether the editor is in a temporary non-mutating state.
760
+ *
761
+ * @returns {boolean} True while loading, animating, cropping, or running a compound operation.
762
+ * @public
763
+ */
764
+ isBusy() {
765
+ return !!(this.isAnimating || this._cropMode || this._isLoading || this._activeOperationToken || this.animationQueue && this.animationQueue.isBusy());
766
+ }
672
767
  /**
673
768
  * Creates an HTMLImageElement from a given data URL.
674
769
  *
@@ -696,7 +791,7 @@ var ImageEditor = class {
696
791
  try {
697
792
  imageElement.src = "";
698
793
  } catch (error) {
699
- void error;
794
+ this._reportWarning("Image timeout cleanup failed", error);
700
795
  }
701
796
  }, safeTimeoutMs);
702
797
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -740,7 +835,6 @@ var ImageEditor = class {
740
835
  _captureLoadImageTransaction() {
741
836
  return {
742
837
  canvasState: this._serializeCanvasState(),
743
- originalImage: this.originalImage,
744
838
  baseImageScale: this.baseImageScale,
745
839
  currentScale: this.currentScale,
746
840
  currentRotation: this.currentRotation,
@@ -765,6 +859,7 @@ var ImageEditor = class {
765
859
  async _rollbackLoadImageTransaction(transaction) {
766
860
  if (!transaction || !this.canvas || this._disposed) return;
767
861
  let didRestoreCanvasState = false;
862
+ let didFailCanvasRestore = false;
768
863
  try {
769
864
  if (transaction.canvasState) {
770
865
  await this.loadFromState(transaction.canvasState);
@@ -772,22 +867,27 @@ var ImageEditor = class {
772
867
  }
773
868
  } catch (error) {
774
869
  this._lastMask = null;
870
+ didFailCanvasRestore = true;
775
871
  this._reportError("loadImage rollback failed", error);
776
872
  }
777
- this.baseImageScale = transaction.baseImageScale;
778
- this.currentScale = transaction.currentScale;
779
- this.currentRotation = transaction.currentRotation;
780
- this.maskCounter = transaction.maskCounter;
781
- this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
782
- this._lastSnapshot = transaction.lastSnapshot;
783
- if (didRestoreCanvasState) {
784
- this._restoreLastMaskReference(transaction.lastMask);
873
+ if (didFailCanvasRestore) {
874
+ this._reconcileEditorStateFromCanvas();
785
875
  } else {
786
- this._lastMask = null;
876
+ this.baseImageScale = transaction.baseImageScale;
877
+ this.currentScale = transaction.currentScale;
878
+ this.currentRotation = transaction.currentRotation;
879
+ this.maskCounter = transaction.maskCounter;
880
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
881
+ this._lastSnapshot = transaction.lastSnapshot;
882
+ if (didRestoreCanvasState) {
883
+ this._restoreLastMaskReference(transaction.lastMask);
884
+ } else {
885
+ this._lastMask = null;
886
+ }
887
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
888
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
889
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
787
890
  }
788
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
789
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
790
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
791
891
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
792
892
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
793
893
  if (this.containerElement) {
@@ -800,6 +900,46 @@ var ImageEditor = class {
800
900
  this._updateUI();
801
901
  if (this.canvas) this.canvas.renderAll();
802
902
  }
903
+ _reconcileEditorStateFromCanvas() {
904
+ if (!this.canvas) {
905
+ this.originalImage = null;
906
+ this.baseImageScale = 1;
907
+ this.currentScale = 1;
908
+ this.currentRotation = 0;
909
+ this.maskCounter = 0;
910
+ this.isImageLoadedToCanvas = false;
911
+ this._lastSnapshot = null;
912
+ this._clearMaskPlacementMemory();
913
+ return;
914
+ }
915
+ const canvasObjects = this.canvas.getObjects();
916
+ this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
917
+ if (this.originalImage) {
918
+ const imageScale = Number(this.originalImage.scaleX) || 1;
919
+ this.baseImageScale = imageScale;
920
+ this.currentScale = 1;
921
+ this.currentRotation = Number(this.originalImage.angle) || 0;
922
+ } else {
923
+ this.baseImageScale = 1;
924
+ this.currentScale = 1;
925
+ this.currentRotation = 0;
926
+ }
927
+ const masks = canvasObjects.filter((object) => object.maskId);
928
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
929
+ this._lastMask = masks[masks.length - 1] || null;
930
+ if (!this._lastMask) {
931
+ this._lastMaskInitialLeft = null;
932
+ this._lastMaskInitialTop = null;
933
+ this._lastMaskInitialWidth = null;
934
+ }
935
+ this.isImageLoadedToCanvas = !!this.originalImage;
936
+ try {
937
+ this._lastSnapshot = this._serializeCanvasState();
938
+ } catch (error) {
939
+ this._lastSnapshot = null;
940
+ this._reportWarning("loadImage rollback: failed to reconcile canvas snapshot", error);
941
+ }
942
+ }
803
943
  _restoreLastMaskReference(previousLastMask) {
804
944
  if (!this.canvas) {
805
945
  this._lastMask = null;
@@ -826,12 +966,19 @@ var ImageEditor = class {
826
966
  * @private
827
967
  */
828
968
  _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
969
+ const sourceWidth = Math.max(1, Number(imageElement && (imageElement.naturalWidth || imageElement.width)) || 0);
970
+ const sourceHeight = Math.max(1, Number(imageElement && (imageElement.naturalHeight || imageElement.height)) || 0);
971
+ const safeTargetWidth = Math.round(Number(targetWidth));
972
+ const safeTargetHeight = Math.round(Number(targetHeight));
973
+ if (!Number.isFinite(safeTargetWidth) || !Number.isFinite(safeTargetHeight) || safeTargetWidth <= 0 || safeTargetHeight <= 0) {
974
+ throw new Error("Invalid image resample target dimensions");
975
+ }
829
976
  const offscreenCanvas = document.createElement("canvas");
830
- offscreenCanvas.width = targetWidth;
831
- offscreenCanvas.height = targetHeight;
977
+ offscreenCanvas.width = safeTargetWidth;
978
+ offscreenCanvas.height = safeTargetHeight;
832
979
  const context = offscreenCanvas.getContext("2d");
833
980
  if (!context) throw new Error("2D canvas context is unavailable");
834
- context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
981
+ context.drawImage(imageElement, 0, 0, sourceWidth, sourceHeight, 0, 0, safeTargetWidth, safeTargetHeight);
835
982
  return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
836
983
  }
837
984
  _getDataUrlMimeType(dataUrl) {
@@ -863,6 +1010,7 @@ var ImageEditor = class {
863
1010
  * @private
864
1011
  */
865
1012
  _setCanvasSizeInt(width, height) {
1013
+ if (!this.canvas) return;
866
1014
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
867
1015
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
868
1016
  this.canvas.setWidth(integerWidth);
@@ -1135,7 +1283,7 @@ var ImageEditor = class {
1135
1283
  /**
1136
1284
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1137
1285
  *
1138
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1286
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1139
1287
  * @private
1140
1288
  */
1141
1289
  _serializeEditorMetadata() {
@@ -1143,12 +1291,16 @@ var ImageEditor = class {
1143
1291
  const currentScale = Number(this.currentScale);
1144
1292
  const currentRotation = Number(this.currentRotation);
1145
1293
  const maskCounter = Number(this.maskCounter);
1294
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1295
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1146
1296
  return {
1147
1297
  version: 1,
1148
1298
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1149
1299
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1150
1300
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1151
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1301
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1302
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1303
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1152
1304
  };
1153
1305
  }
1154
1306
  _serializeCanvasState() {
@@ -1332,10 +1484,42 @@ var ImageEditor = class {
1332
1484
  }
1333
1485
  _getJpegBackgroundColor() {
1334
1486
  const backgroundColor = String(this.options.backgroundColor || "").trim();
1335
- if (!backgroundColor || backgroundColor === "transparent") return "#ffffff";
1336
- if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return "#ffffff";
1487
+ if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return "#ffffff";
1337
1488
  return backgroundColor;
1338
1489
  }
1490
+ _isTransparentCssColor(color) {
1491
+ const normalizedColor = String(color || "").trim().toLowerCase();
1492
+ if (!normalizedColor || normalizedColor === "transparent") return true;
1493
+ const hexAlphaMatch = normalizedColor.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/i);
1494
+ if (hexAlphaMatch) {
1495
+ const alpha = hexAlphaMatch[1] || hexAlphaMatch[2];
1496
+ return alpha === "0" || alpha === "00";
1497
+ }
1498
+ const slashAlphaMatch = normalizedColor.match(/^(?:rgba?|hsla?)\([^)]*\/\s*([^)]+)\)$/i);
1499
+ if (slashAlphaMatch) return this._isZeroCssAlpha(slashAlphaMatch[1]);
1500
+ const commaAlphaMatch = normalizedColor.match(/^(?:rgba|hsla)\((.*)\)$/i);
1501
+ if (commaAlphaMatch) {
1502
+ const parts = commaAlphaMatch[1].split(",");
1503
+ if (parts.length >= 4) return this._isZeroCssAlpha(parts[parts.length - 1]);
1504
+ }
1505
+ return false;
1506
+ }
1507
+ _isZeroCssAlpha(alphaValue) {
1508
+ const normalizedAlpha = String(alphaValue || "").trim();
1509
+ if (!normalizedAlpha) return false;
1510
+ if (normalizedAlpha.endsWith("%")) return Number.parseFloat(normalizedAlpha) === 0;
1511
+ return Number(normalizedAlpha) === 0;
1512
+ }
1513
+ _decodeBase64Payload(base64Payload) {
1514
+ const payload = String(base64Payload || "");
1515
+ if (typeof atob === "function") {
1516
+ return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
1517
+ }
1518
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
1519
+ return new Uint8Array(Buffer.from(payload, "base64"));
1520
+ }
1521
+ throw new Error("Base64 decoding is unavailable");
1522
+ }
1339
1523
  /**
1340
1524
  * Gets the top-left corner coordinates of the given object.
1341
1525
  * Used for geometry calculations (e.g., scale, rotate).
@@ -1452,17 +1636,13 @@ var ImageEditor = class {
1452
1636
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1453
1637
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1454
1638
  });
1455
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1456
1639
  let minWidth = 0;
1457
1640
  let minHeight = 0;
1458
- if (shouldUseScrollSafeViewport) {
1641
+ if (this.containerElement) {
1459
1642
  const viewport = this._getContainerViewportSize();
1460
1643
  const safetyMargin = this._getScrollSafetyMargin();
1461
1644
  minWidth = Math.max(1, viewport.width - safetyMargin);
1462
1645
  minHeight = Math.max(1, viewport.height - safetyMargin);
1463
- } else if (this.containerElement) {
1464
- minWidth = Math.floor(this.containerElement.clientWidth || 0);
1465
- minHeight = Math.floor(this.containerElement.clientHeight || 0);
1466
1646
  }
1467
1647
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1468
1648
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1531,9 +1711,15 @@ var ImageEditor = class {
1531
1711
  _assertEditorAvailable(operationName) {
1532
1712
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1533
1713
  }
1714
+ _isCropModeAllowedOperation(operationName) {
1715
+ return operationName === "applyCrop" || operationName === "cancelCrop";
1716
+ }
1534
1717
  _assertIdleForOperation(operationName, options = {}) {
1535
1718
  this._assertEditorAvailable(operationName);
1536
1719
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
1720
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1721
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1722
+ }
1537
1723
  if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1538
1724
  throw new Error(`${operationName} cannot run while an animation is running`);
1539
1725
  }
@@ -1546,10 +1732,14 @@ var ImageEditor = class {
1546
1732
  }
1547
1733
  _assertCanQueueAnimation(operationName, options = {}) {
1548
1734
  this._assertEditorAvailable(operationName);
1549
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
1735
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1736
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1737
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1738
+ }
1739
+ if (this._isLoading && !isOwnInternalOperation) {
1550
1740
  throw new Error(`${operationName} cannot run while an image is loading`);
1551
1741
  }
1552
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1742
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1553
1743
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1554
1744
  }
1555
1745
  }
@@ -1736,10 +1926,19 @@ var ImageEditor = class {
1736
1926
  }
1737
1927
  return this.animationQueue.add(async () => {
1738
1928
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1739
- await this._scaleImageImpl(1, { saveHistory: false });
1740
- await this._rotateImageImpl(0, { saveHistory: false });
1741
- const after = this._captureCanvasStateOrThrow("resetImageTransform");
1742
- this._pushStateTransition(before, after);
1929
+ try {
1930
+ await this._scaleImageImpl(1, { saveHistory: false });
1931
+ await this._rotateImageImpl(0, { saveHistory: false });
1932
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1933
+ this._pushStateTransition(before, after);
1934
+ } catch (error) {
1935
+ try {
1936
+ await this.loadFromState(before);
1937
+ } catch (restoreError) {
1938
+ this._reportError("resetImageTransform rollback failed", restoreError);
1939
+ }
1940
+ throw error;
1941
+ }
1743
1942
  }).finally(() => {
1744
1943
  if (!this._disposed && this.canvas) this._updateUI();
1745
1944
  }).catch((error) => {
@@ -1778,7 +1977,13 @@ var ImageEditor = class {
1778
1977
  try {
1779
1978
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1780
1979
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1781
- this.canvas.loadFromJSON(state, async () => {
1980
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
1981
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
1982
+ const hasRestoredCanvasSize = Number.isFinite(restoredCanvasWidth) && restoredCanvasWidth > 0 && Number.isFinite(restoredCanvasHeight) && restoredCanvasHeight > 0;
1983
+ if (editorMetadata && Object.prototype.hasOwnProperty.call(editorMetadata, "version") && Number(editorMetadata.version) !== 1) {
1984
+ this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
1985
+ }
1986
+ const finishLoad = async () => {
1782
1987
  try {
1783
1988
  if (this._disposed || !this.canvas) {
1784
1989
  reject(new Error("Editor was disposed while loading state"));
@@ -1814,6 +2019,11 @@ var ImageEditor = class {
1814
2019
  this.currentScale = 1;
1815
2020
  this.currentRotation = 0;
1816
2021
  }
2022
+ if (hasRestoredCanvasSize) {
2023
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2024
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2025
+ this._updateCanvasSizeToImageBounds();
2026
+ }
1817
2027
  const masks = canvasObjects.filter((object) => object.maskId);
1818
2028
  masks.forEach((mask) => {
1819
2029
  this._restoreMaskControls(mask);
@@ -1841,6 +2051,9 @@ var ImageEditor = class {
1841
2051
  this._reportError("loadFromState() failed", callbackError);
1842
2052
  reject(callbackError);
1843
2053
  }
2054
+ };
2055
+ this.canvas.loadFromJSON(state, () => {
2056
+ void finishLoad();
1844
2057
  });
1845
2058
  } catch (error) {
1846
2059
  this._reportError("loadFromState() failed", error);
@@ -1856,12 +2069,12 @@ var ImageEditor = class {
1856
2069
  }
1857
2070
  _waitForImageElementReady(imageElement) {
1858
2071
  if (!imageElement) return Promise.resolve();
1859
- if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
2072
+ const hasLoadedDimensions = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) && (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2073
+ if (hasLoadedDimensions) return Promise.resolve();
2074
+ if (imageElement.complete) return Promise.reject(new Error("Image could not be loaded while restoring state"));
1860
2075
  return new Promise((resolve, reject) => {
1861
2076
  let isSettled = false;
1862
- const timerId = setTimeout(() => {
1863
- settle(() => reject(new Error("Image load timed out while restoring state")));
1864
- }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2077
+ let timerId;
1865
2078
  const settle = (callback) => {
1866
2079
  if (isSettled) return;
1867
2080
  isSettled = true;
@@ -1875,8 +2088,20 @@ var ImageEditor = class {
1875
2088
  }
1876
2089
  callback();
1877
2090
  };
1878
- const handleLoad = () => settle(resolve);
1879
- const handleError = (error) => settle(() => reject(error));
2091
+ const handleLoad = () => {
2092
+ const didLoad = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) && (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2093
+ settle(() => {
2094
+ if (didLoad) {
2095
+ resolve();
2096
+ } else {
2097
+ reject(new Error("Image could not be loaded while restoring state"));
2098
+ }
2099
+ });
2100
+ };
2101
+ const handleError = (error) => settle(() => reject(error instanceof Error ? error : new Error("Image could not be loaded while restoring state")));
2102
+ timerId = setTimeout(() => {
2103
+ settle(() => reject(new Error("Image load timed out while restoring state")));
2104
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1880
2105
  if (typeof imageElement.addEventListener === "function") {
1881
2106
  imageElement.addEventListener("load", handleLoad, { once: true });
1882
2107
  imageElement.addEventListener("error", handleError, { once: true });
@@ -1976,14 +2201,7 @@ var ImageEditor = class {
1976
2201
  }
1977
2202
  _rebindMaskEvents(mask) {
1978
2203
  if (!mask) return;
1979
- if (mask.__imageEditorMaskHandlers) {
1980
- try {
1981
- mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
1982
- mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
1983
- } catch (error) {
1984
- void error;
1985
- }
1986
- }
2204
+ this._cleanupMaskEvents(mask);
1987
2205
  const metadata = {};
1988
2206
  if (!Number.isFinite(Number(mask.originalAlpha))) {
1989
2207
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
@@ -2010,6 +2228,22 @@ var ImageEditor = class {
2010
2228
  mask.on("mouseout", mouseout);
2011
2229
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2012
2230
  }
2231
+ _cleanupMaskEvents(mask) {
2232
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2233
+ try {
2234
+ if (typeof mask.off === "function") {
2235
+ mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2236
+ mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2237
+ }
2238
+ } catch (error) {
2239
+ this._reportWarning("Mask event cleanup failed", error);
2240
+ }
2241
+ try {
2242
+ delete mask.__imageEditorMaskHandlers;
2243
+ } catch (error) {
2244
+ this._reportWarning("Mask event metadata cleanup failed", error);
2245
+ }
2246
+ }
2013
2247
  /**
2014
2248
  * Creates a mask and adds it to the canvas.
2015
2249
  *
@@ -2148,6 +2382,10 @@ var ImageEditor = class {
2148
2382
  });
2149
2383
  }
2150
2384
  }
2385
+ if (!mask || typeof mask.set !== "function" || typeof mask.setCoords !== "function") {
2386
+ this._reportWarning("fabricGenerator returned an invalid Fabric object");
2387
+ return null;
2388
+ }
2151
2389
  const styles = maskConfig.styles || {};
2152
2390
  const hasStyle = (property) => Object.prototype.hasOwnProperty.call(styles, property);
2153
2391
  const maskSettings = {
@@ -2216,6 +2454,7 @@ var ImageEditor = class {
2216
2454
  this.canvas.discardActiveObject();
2217
2455
  selectedMasks.forEach((mask) => {
2218
2456
  this._removeLabelForMask(mask);
2457
+ this._cleanupMaskEvents(mask);
2219
2458
  this.canvas.remove(mask);
2220
2459
  });
2221
2460
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -2240,7 +2479,10 @@ var ImageEditor = class {
2240
2479
  const saveHistory = options.saveHistory !== false;
2241
2480
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2242
2481
  masks.forEach((mask) => this._removeLabelForMask(mask));
2243
- masks.forEach((mask) => this.canvas.remove(mask));
2482
+ masks.forEach((mask) => {
2483
+ this._cleanupMaskEvents(mask);
2484
+ this.canvas.remove(mask);
2485
+ });
2244
2486
  this.canvas.discardActiveObject();
2245
2487
  this._lastMask = null;
2246
2488
  this._lastMaskInitialLeft = null;
@@ -2275,6 +2517,93 @@ var ImageEditor = class {
2275
2517
  }
2276
2518
  }
2277
2519
  }
2520
+ _captureMaskLabelBackups(masks) {
2521
+ if (!this.canvas) return [];
2522
+ const canvasObjects = new Set(this.canvas.getObjects());
2523
+ return (masks || []).map((mask) => {
2524
+ const label = mask && mask.__label ? mask.__label : null;
2525
+ return {
2526
+ mask,
2527
+ label,
2528
+ hadLabel: !!label,
2529
+ labelInCanvas: !!label && canvasObjects.has(label),
2530
+ visible: label ? label.visible : void 0
2531
+ };
2532
+ });
2533
+ }
2534
+ _restoreMaskLabelBackups(labelBackups) {
2535
+ if (!this.canvas || !Array.isArray(labelBackups)) return;
2536
+ const canvasObjects = new Set(this.canvas.getObjects());
2537
+ labelBackups.forEach((backup) => {
2538
+ if (!backup || !backup.mask) return;
2539
+ try {
2540
+ if (!backup.hadLabel) {
2541
+ if (backup.mask.__label) this._removeLabelForMask(backup.mask);
2542
+ return;
2543
+ }
2544
+ backup.mask.__label = backup.label;
2545
+ if (!backup.label) return;
2546
+ if (backup.labelInCanvas && !canvasObjects.has(backup.label)) {
2547
+ this.canvas.add(backup.label);
2548
+ canvasObjects.add(backup.label);
2549
+ }
2550
+ if (backup.visible !== void 0) backup.label.set({ visible: backup.visible });
2551
+ if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2552
+ this._syncMaskLabel(backup.mask);
2553
+ } catch (error) {
2554
+ this._reportWarning("restoreMaskLabelBackups: failed to restore mask label", error);
2555
+ }
2556
+ });
2557
+ }
2558
+ _captureActiveObjectBackup() {
2559
+ if (!this.canvas) return null;
2560
+ const activeObject = this.canvas.getActiveObject();
2561
+ if (!activeObject) return null;
2562
+ const selectedObjects = typeof activeObject.getObjects === "function" ? activeObject.getObjects() : [activeObject];
2563
+ return { activeObject, selectedObjects };
2564
+ }
2565
+ _restoreActiveObjectBackup(activeObjectBackup) {
2566
+ if (!this.canvas || !activeObjectBackup || !activeObjectBackup.activeObject) return;
2567
+ const canvasObjects = this.canvas.getObjects();
2568
+ const selectedObjects = Array.isArray(activeObjectBackup.selectedObjects) ? activeObjectBackup.selectedObjects : [];
2569
+ const canRestore = selectedObjects.length ? selectedObjects.every((object) => canvasObjects.includes(object)) : canvasObjects.includes(activeObjectBackup.activeObject);
2570
+ if (!canRestore) return;
2571
+ try {
2572
+ this.canvas.setActiveObject(activeObjectBackup.activeObject);
2573
+ } catch (error) {
2574
+ void error;
2575
+ }
2576
+ }
2577
+ _captureMaskExportBackups(masks) {
2578
+ return (masks || []).map((mask) => ({
2579
+ object: mask,
2580
+ visible: mask.visible,
2581
+ opacity: mask.opacity,
2582
+ fill: mask.fill,
2583
+ strokeWidth: mask.strokeWidth,
2584
+ stroke: mask.stroke,
2585
+ selectable: mask.selectable,
2586
+ lockRotation: mask.lockRotation
2587
+ }));
2588
+ }
2589
+ _restoreMaskExportBackups(maskBackups) {
2590
+ (maskBackups || []).forEach((backup) => {
2591
+ try {
2592
+ backup.object.set({
2593
+ visible: backup.visible,
2594
+ opacity: backup.opacity,
2595
+ fill: backup.fill,
2596
+ strokeWidth: backup.strokeWidth,
2597
+ stroke: backup.stroke,
2598
+ selectable: backup.selectable,
2599
+ lockRotation: backup.lockRotation
2600
+ });
2601
+ backup.object.setCoords();
2602
+ } catch (error) {
2603
+ void error;
2604
+ }
2605
+ });
2606
+ }
2278
2607
  /**
2279
2608
  * Returns a stable zero-based creation index for label callbacks.
2280
2609
  *
@@ -2347,10 +2676,13 @@ var ImageEditor = class {
2347
2676
  _hideAllMaskLabels() {
2348
2677
  if (!this.canvas) return;
2349
2678
  const canvasObjects = this.canvas.getObjects();
2679
+ const canvasObjectSet = new Set(canvasObjects);
2350
2680
  const labels = canvasObjects.filter((object) => object.maskLabel);
2351
2681
  labels.forEach((label) => {
2352
2682
  try {
2353
- if (canvasObjects.includes(label)) this.canvas.remove(label);
2683
+ if (canvasObjectSet.has(label)) {
2684
+ this.canvas.remove(label);
2685
+ }
2354
2686
  } catch (error) {
2355
2687
  void error;
2356
2688
  }
@@ -2520,6 +2852,9 @@ var ImageEditor = class {
2520
2852
  fileType: "png"
2521
2853
  }));
2522
2854
  this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2855
+ if (this.canvas.getObjects().some((object) => object.maskId)) {
2856
+ throw new Error("Masks could not be removed during merge");
2857
+ }
2523
2858
  await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2524
2859
  preserveScroll: true,
2525
2860
  resetMaskCounter: false
@@ -2593,7 +2928,11 @@ var ImageEditor = class {
2593
2928
  const format = this._normalizeImageFormat(options.fileType || options.format);
2594
2929
  if (!exportImageArea) {
2595
2930
  const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
2931
+ const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
2596
2932
  const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
2933
+ const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
2934
+ const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
2935
+ const activeObjectBackup2 = this._captureActiveObjectBackup();
2597
2936
  try {
2598
2937
  masks2.forEach((mask) => {
2599
2938
  mask.set({ visible: false });
@@ -2618,20 +2957,16 @@ var ImageEditor = class {
2618
2957
  void error;
2619
2958
  }
2620
2959
  });
2960
+ this._restoreMaskExportBackups(maskStyleBackups2);
2961
+ this._restoreMaskLabelBackups(labelBackups2);
2962
+ this._restoreActiveObjectBackup(activeObjectBackup2);
2621
2963
  this.canvas.renderAll();
2622
2964
  }
2623
2965
  }
2624
2966
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2625
- const maskStyleBackups = masks.map((mask) => ({
2626
- object: mask,
2627
- opacity: mask.opacity,
2628
- fill: mask.fill,
2629
- strokeWidth: mask.strokeWidth,
2630
- stroke: mask.stroke,
2631
- selectable: mask.selectable,
2632
- lockRotation: mask.lockRotation
2633
- }));
2634
- let finalBase64;
2967
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
2968
+ const labelBackups = this._captureMaskLabelBackups(masks);
2969
+ const activeObjectBackup = this._captureActiveObjectBackup();
2635
2970
  try {
2636
2971
  masks.forEach((mask) => this._removeLabelForMask(mask));
2637
2972
  this.canvas.discardActiveObject();
@@ -2644,7 +2979,7 @@ var ImageEditor = class {
2644
2979
  this.originalImage.setCoords();
2645
2980
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2646
2981
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
2647
- finalBase64 = await this._exportCanvasRegionToDataURL({
2982
+ return await this._exportCanvasRegionToDataURL({
2648
2983
  ...exportRegion,
2649
2984
  multiplier,
2650
2985
  quality,
@@ -2652,24 +2987,11 @@ var ImageEditor = class {
2652
2987
  sealPartialEdges: this._getPartialExportEdges(imageBounds)
2653
2988
  });
2654
2989
  } finally {
2655
- maskStyleBackups.forEach((backup) => {
2656
- try {
2657
- backup.object.set({
2658
- opacity: backup.opacity,
2659
- fill: backup.fill,
2660
- strokeWidth: backup.strokeWidth,
2661
- stroke: backup.stroke,
2662
- selectable: backup.selectable,
2663
- lockRotation: backup.lockRotation
2664
- });
2665
- backup.object.setCoords();
2666
- } catch (error) {
2667
- void error;
2668
- }
2669
- });
2990
+ this._restoreMaskExportBackups(maskStyleBackups);
2991
+ this._restoreMaskLabelBackups(labelBackups);
2992
+ this._restoreActiveObjectBackup(activeObjectBackup);
2670
2993
  this.canvas.renderAll();
2671
2994
  }
2672
- return finalBase64;
2673
2995
  }
2674
2996
  /**
2675
2997
  * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
@@ -2750,13 +3072,8 @@ var ImageEditor = class {
2750
3072
  imageElement.src = imageBase64;
2751
3073
  });
2752
3074
  }
2753
- const binaryString = atob(imageDataUrl.split(",")[1]);
3075
+ const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
2754
3076
  const mime = `image/${safeFileType}`;
2755
- let byteIndex = binaryString.length;
2756
- const bytes = new Uint8Array(byteIndex);
2757
- while (byteIndex--) {
2758
- bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
2759
- }
2760
3077
  return new File([bytes], fileName, { type: mime });
2761
3078
  }
2762
3079
  _clearMaskPlacementMemory() {
@@ -2801,22 +3118,21 @@ var ImageEditor = class {
2801
3118
  this._cropPrevEvented = null;
2802
3119
  }
2803
3120
  _removeCropRect() {
2804
- if (!this._cropRect) return;
2805
- try {
2806
- if (this._cropHandlers && this._cropHandlers.length) {
2807
- this._cropHandlers.forEach((targetHandlers) => {
2808
- targetHandlers.handlers.forEach((handlerRecord) => {
3121
+ if (this._cropHandlers && this._cropHandlers.length) {
3122
+ this._cropHandlers.forEach((targetHandlers) => {
3123
+ (targetHandlers.handlers || []).forEach((handlerRecord) => {
3124
+ try {
2809
3125
  if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2810
3126
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2811
3127
  }
2812
- });
3128
+ } catch (error) {
3129
+ this._reportWarning("Crop handler cleanup failed", error);
3130
+ }
2813
3131
  });
2814
- }
2815
- } catch (error) {
2816
- void error;
3132
+ });
2817
3133
  }
2818
3134
  try {
2819
- if (this.canvas) this.canvas.remove(this._cropRect);
3135
+ if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect);
2820
3136
  } catch (error) {
2821
3137
  void error;
2822
3138
  }
@@ -2903,6 +3219,30 @@ var ImageEditor = class {
2903
3219
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2904
3220
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2905
3221
  cropRect.setCoords();
3222
+ const cropBounds = cropRect.getBoundingRect(true, true);
3223
+ const imageLeft = Number(imageBounds.left) || 0;
3224
+ const imageTop = Number(imageBounds.top) || 0;
3225
+ const imageRight = imageLeft + (Number(imageBounds.width) || 0);
3226
+ const imageBottom = imageTop + (Number(imageBounds.height) || 0);
3227
+ let deltaX = 0;
3228
+ let deltaY = 0;
3229
+ if (cropBounds.left < imageLeft) {
3230
+ deltaX = imageLeft - cropBounds.left;
3231
+ } else if (cropBounds.left + cropBounds.width > imageRight) {
3232
+ deltaX = imageRight - (cropBounds.left + cropBounds.width);
3233
+ }
3234
+ if (cropBounds.top < imageTop) {
3235
+ deltaY = imageTop - cropBounds.top;
3236
+ } else if (cropBounds.top + cropBounds.height > imageBottom) {
3237
+ deltaY = imageBottom - (cropBounds.top + cropBounds.height);
3238
+ }
3239
+ if (deltaX || deltaY) {
3240
+ cropRect.set({
3241
+ left: (Number(cropRect.left) || 0) + deltaX,
3242
+ top: (Number(cropRect.top) || 0) + deltaY
3243
+ });
3244
+ cropRect.setCoords();
3245
+ }
2906
3246
  this.canvas.requestRenderAll();
2907
3247
  } catch (error) {
2908
3248
  void error;
@@ -2962,27 +3302,28 @@ var ImageEditor = class {
2962
3302
  try {
2963
3303
  beforeJson = this._serializeCanvasState();
2964
3304
  } catch (error) {
2965
- this._reportWarning("applyCrop: could not serialize before state", error);
3305
+ this._reportError("applyCrop: failed to capture rollback state", error);
2966
3306
  beforeJson = null;
2967
3307
  }
3308
+ if (!beforeJson) {
3309
+ this.cancelCrop();
3310
+ return;
3311
+ }
2968
3312
  const preservedMasks = [];
2969
3313
  try {
2970
3314
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2971
3315
  if (masks && masks.length) {
2972
3316
  masks.forEach((mask) => {
2973
- try {
2974
- mask.setCoords();
2975
- const maskBounds = mask.getBoundingRect(true, true);
2976
- 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;
2977
- this._removeLabelForMask(mask);
2978
- this.canvas.remove(mask);
2979
- if (shouldPreserveMasks && intersectsCrop) {
2980
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2981
- mask.set({ visible: true });
2982
- preservedMasks.push(mask);
2983
- }
2984
- } catch (error) {
2985
- this._reportWarning("applyCrop: failed to remove mask", error);
3317
+ mask.setCoords();
3318
+ const maskBounds = mask.getBoundingRect(true, true);
3319
+ const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3320
+ this._removeLabelForMask(mask);
3321
+ this._cleanupMaskEvents(mask);
3322
+ this.canvas.remove(mask);
3323
+ if (shouldPreserveMasks && intersectsCrop) {
3324
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3325
+ mask.set({ visible: true });
3326
+ preservedMasks.push(mask);
2986
3327
  }
2987
3328
  });
2988
3329
  this._clearMaskPlacementMemory();
@@ -2990,7 +3331,8 @@ var ImageEditor = class {
2990
3331
  this.canvas.renderAll();
2991
3332
  }
2992
3333
  } catch (error) {
2993
- this._reportWarning("applyCrop: error while removing masks", error);
3334
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error);
3335
+ return;
2994
3336
  }
2995
3337
  this._removeCropRect();
2996
3338
  this._cropMode = false;
@@ -3047,7 +3389,7 @@ var ImageEditor = class {
3047
3389
  * @private
3048
3390
  */
3049
3391
  _updateInputs() {
3050
- const scaleInputElement = this._getElement("scaleRate");
3392
+ const scaleInputElement = this._getElement("scalePercentageInput");
3051
3393
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3052
3394
  }
3053
3395
  /**
@@ -3066,12 +3408,12 @@ var ImageEditor = class {
3066
3408
  const canUndo = this.historyManager?.canUndo();
3067
3409
  const canRedo = this.historyManager?.canRedo();
3068
3410
  const isInCropMode = !!this._cropMode;
3069
- const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
3411
+ const isBusy = this.isBusy();
3070
3412
  if (isInCropMode) {
3071
3413
  for (const key of Object.keys(this.elements || {})) {
3072
3414
  const element = this._getElement(key);
3073
3415
  if (!element) continue;
3074
- if (key === "applyCropBtn" || key === "cancelCropBtn") {
3416
+ if (key === "applyCropButton" || key === "cancelCropButton" || key === "applyCropBtn" || key === "cancelCropBtn") {
3075
3417
  this._setDisabled(key, false);
3076
3418
  } else {
3077
3419
  this._setDisabled(key, true);
@@ -3079,28 +3421,32 @@ var ImageEditor = class {
3079
3421
  }
3080
3422
  return;
3081
3423
  }
3082
- this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3083
- this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3084
- this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3085
- this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3086
- this._setDisabled("addMaskBtn", !hasImage || isBusy);
3087
- this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3088
- this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3089
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3090
- this._setDisabled("downloadBtn", !hasImage || isBusy);
3091
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3092
- this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3093
- this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3094
- this._setDisabled("cropBtn", !hasImage || isBusy);
3095
- this._setDisabled("applyCropBtn", true);
3096
- this._setDisabled("cancelCropBtn", true);
3424
+ this._setDisabled("zoomInButton", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3425
+ this._setDisabled("zoomOutButton", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3426
+ this._setDisabled("rotateLeftButton", !hasImage || isBusy);
3427
+ this._setDisabled("rotateRightButton", !hasImage || isBusy);
3428
+ this._setDisabled("createMaskButton", !hasImage || isBusy);
3429
+ this._setDisabled("removeSelectedMaskButton", !hasSelectedMask || isBusy);
3430
+ this._setDisabled("removeAllMasksButton", !hasMasks || isBusy);
3431
+ this._setDisabled("mergeMasksButton", !hasImage || !hasMasks || isBusy);
3432
+ this._setDisabled("downloadImageButton", !hasImage || isBusy);
3433
+ this._setDisabled("resetImageTransformButton", !hasImage || isDefaultTransform || isBusy);
3434
+ this._setDisabled("undoButton", !hasImage || isBusy || !canUndo);
3435
+ this._setDisabled("redoButton", !hasImage || isBusy || !canRedo);
3436
+ this._setDisabled("enterCropModeButton", !hasImage || isBusy);
3437
+ this._setDisabled("applyCropButton", true);
3438
+ this._setDisabled("cancelCropButton", true);
3439
+ this._setDisabled("scalePercentageInput", !hasImage || isBusy);
3440
+ this._setDisabled("rotateLeftDegreesInput", !hasImage || isBusy);
3441
+ this._setDisabled("rotateRightDegreesInput", !hasImage || isBusy);
3442
+ this._setDisabled("maskList", !hasImage || isBusy);
3097
3443
  this._setDisabled("imageInput", isBusy);
3098
3444
  this._setDisabled("uploadArea", isBusy);
3099
3445
  }
3100
3446
  /**
3101
3447
  * Enables or disables a specific UI element (typically a button) by its key.
3102
3448
  *
3103
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
3449
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3104
3450
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3105
3451
  * @private
3106
3452
  */
@@ -3224,14 +3570,7 @@ var ImageEditor = class {
3224
3570
  } catch (error) {
3225
3571
  void error;
3226
3572
  }
3227
- if (this._cropRect) {
3228
- try {
3229
- this.canvas.remove(this._cropRect);
3230
- } catch (error) {
3231
- void error;
3232
- }
3233
- this._cropRect = null;
3234
- }
3573
+ if (this._cropRect) this._removeCropRect();
3235
3574
  if (this.containerElement && this._containerOriginalOverflow) {
3236
3575
  try {
3237
3576
  this._restoreContainerOverflowState();
@@ -3254,11 +3593,19 @@ var ImageEditor = class {
3254
3593
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3255
3594
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3256
3595
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3596
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3257
3597
  } catch (error) {
3258
3598
  void error;
3259
3599
  }
3260
3600
  }
3261
3601
  if (this.canvas) {
3602
+ try {
3603
+ this.canvas.getObjects().forEach((object) => {
3604
+ if (object && object.maskId) this._cleanupMaskEvents(object);
3605
+ });
3606
+ } catch (error) {
3607
+ void error;
3608
+ }
3262
3609
  try {
3263
3610
  this.canvas.dispose();
3264
3611
  } catch (error) {
@@ -3355,7 +3702,7 @@ var AnimationQueue = class {
3355
3702
  task.reject(error);
3356
3703
  }
3357
3704
  } finally {
3358
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3705
+ if (this.currentTask === task) this.currentTask = null;
3359
3706
  }
3360
3707
  }
3361
3708
  } finally {
@@ -3408,9 +3755,9 @@ var HistoryManager = class {
3408
3755
  execute(command) {
3409
3756
  const result = command.execute();
3410
3757
  if (result && typeof result.then === "function") {
3411
- return Promise.resolve(result).then(() => {
3758
+ return this.enqueue(() => Promise.resolve(result).then(() => {
3412
3759
  this.push(command);
3413
- });
3760
+ }));
3414
3761
  }
3415
3762
  this.push(command);
3416
3763
  return result;