@bensitu/image-editor 1.4.2 → 1.5.1

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