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