@bensitu/image-editor 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.4.2
8
+ * @version 1.5.0
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -146,7 +146,7 @@ var ImageEditor = class {
146
146
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
147
  this._disposed = false;
148
148
  this._initialized = false;
149
- this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
149
+ this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
150
150
  this.animationQueue = new AnimationQueue();
151
151
  this.historyManager = new HistoryManager(this.maxHistorySize);
152
152
  }
@@ -192,10 +192,12 @@ var ImageEditor = class {
192
192
  * Use this method to set up the editor UI before interacting with it.
193
193
  *
194
194
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
195
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
196
- * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
197
- * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
198
- * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
195
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
196
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
197
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
198
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
199
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
200
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
199
201
  *
200
202
  * @returns {void}
201
203
  *
@@ -204,7 +206,7 @@ var ImageEditor = class {
204
206
  * @example
205
207
  * editor.init({
206
208
  * canvas: 'myFabricCanvasId',
207
- * downloadBtn: 'myDownloadButtonId'
209
+ * downloadImageButton: 'myDownloadButtonId'
208
210
  * });
209
211
  */
210
212
  init(idMap = {}) {
@@ -223,33 +225,53 @@ var ImageEditor = class {
223
225
  this._containerOriginalOverflow = null;
224
226
  this._lastContainerViewportSize = null;
225
227
  this._canvasElementOriginalStyle = null;
228
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
226
229
  const defaults = {
227
230
  canvas: "fabricCanvas",
228
231
  canvasContainer: null,
229
232
  // Pass an ID here if you have a scrollable viewport container
230
- imgPlaceholder: "imgPlaceholder",
231
- scaleRate: "scaleRate",
232
- rotationLeftInput: "rotationLeftInput",
233
- rotationRightInput: "rotationRightInput",
234
- rotateLeftBtn: "rotateLeftBtn",
235
- rotateRightBtn: "rotateRightBtn",
236
- addMaskBtn: "addMaskBtn",
237
- removeMaskBtn: "removeMaskBtn",
238
- removeAllMasksBtn: "removeAllMasksBtn",
239
- mergeBtn: "mergeBtn",
240
- downloadBtn: "downloadBtn",
233
+ imagePlaceholder: "imagePlaceholder",
234
+ imgPlaceholder: null,
235
+ scalePercentageInput: "scalePercentageInput",
236
+ scaleRate: null,
237
+ rotateLeftDegreesInput: "rotateLeftDegreesInput",
238
+ rotationLeftInput: null,
239
+ rotateRightDegreesInput: "rotateRightDegreesInput",
240
+ rotationRightInput: null,
241
+ rotateLeftButton: "rotateLeftButton",
242
+ rotateLeftBtn: null,
243
+ rotateRightButton: "rotateRightButton",
244
+ rotateRightBtn: null,
245
+ createMaskButton: "createMaskButton",
246
+ addMaskBtn: null,
247
+ removeSelectedMaskButton: "removeSelectedMaskButton",
248
+ removeMaskBtn: null,
249
+ removeAllMasksButton: "removeAllMasksButton",
250
+ removeAllMasksBtn: null,
251
+ mergeMasksButton: "mergeMasksButton",
252
+ mergeBtn: null,
253
+ downloadImageButton: "downloadImageButton",
254
+ downloadBtn: null,
241
255
  maskList: "maskList",
242
- zoomInBtn: "zoomInBtn",
243
- zoomOutBtn: "zoomOutBtn",
244
- resetBtn: "resetBtn",
245
- undoBtn: "undoBtn",
246
- redoBtn: "redoBtn",
256
+ zoomInButton: "zoomInButton",
257
+ zoomInBtn: null,
258
+ zoomOutButton: "zoomOutButton",
259
+ zoomOutBtn: null,
260
+ resetImageTransformButton: "resetImageTransformButton",
261
+ resetBtn: null,
262
+ undoButton: "undoButton",
263
+ undoBtn: null,
264
+ redoButton: "redoButton",
265
+ redoBtn: null,
247
266
  imageInput: "imageInput",
248
- cropBtn: "cropBtn",
249
- applyCropBtn: "applyCropBtn",
250
- cancelCropBtn: "cancelCropBtn"
267
+ enterCropModeButton: "enterCropModeButton",
268
+ cropBtn: null,
269
+ applyCropButton: "applyCropButton",
270
+ applyCropBtn: null,
271
+ cancelCropButton: "cancelCropButton",
272
+ cancelCropBtn: null
251
273
  };
252
- this.elements = { ...defaults, ...idMap };
274
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
253
275
  this._elementCache = {};
254
276
  this._initCanvas();
255
277
  this._bindEvents();
@@ -262,6 +284,63 @@ var ImageEditor = class {
262
284
  this._updatePlaceholderStatus();
263
285
  }
264
286
  }
287
+ _resolveElementIdMap(idMap, defaults) {
288
+ const resolved = { ...defaults, ...idMap };
289
+ this._resolveElementAliases(resolved, idMap, defaults, "imagePlaceholder", ["imgPlaceholder"]);
290
+ this._resolveElementAliases(resolved, idMap, defaults, "scalePercentageInput", ["scaleRate"]);
291
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateLeftDegreesInput", ["rotationLeftInput"]);
292
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateRightDegreesInput", ["rotationRightInput"]);
293
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateLeftButton", "rotateLeftBtn");
294
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateRightButton", "rotateRightBtn");
295
+ this._resolveElementAlias(resolved, idMap, defaults, "createMaskButton", "addMaskBtn");
296
+ this._resolveElementAliases(resolved, idMap, defaults, "removeSelectedMaskButton", ["removeMaskBtn"]);
297
+ this._resolveElementAlias(resolved, idMap, defaults, "removeAllMasksButton", "removeAllMasksBtn");
298
+ this._resolveElementAlias(resolved, idMap, defaults, "mergeMasksButton", "mergeBtn");
299
+ this._resolveElementAliases(resolved, idMap, defaults, "downloadImageButton", ["downloadBtn"]);
300
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomInButton", "zoomInBtn");
301
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomOutButton", "zoomOutBtn");
302
+ this._resolveElementAlias(resolved, idMap, defaults, "resetImageTransformButton", "resetBtn");
303
+ this._resolveElementAlias(resolved, idMap, defaults, "undoButton", "undoBtn");
304
+ this._resolveElementAlias(resolved, idMap, defaults, "redoButton", "redoBtn");
305
+ this._resolveElementAliases(resolved, idMap, defaults, "enterCropModeButton", ["cropBtn"]);
306
+ this._resolveElementAlias(resolved, idMap, defaults, "applyCropButton", "applyCropBtn");
307
+ this._resolveElementAlias(resolved, idMap, defaults, "cancelCropButton", "cancelCropBtn");
308
+ return resolved;
309
+ }
310
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
311
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
312
+ }
313
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
314
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
315
+ if (hasCanonicalKey) {
316
+ resolved[canonicalKey] = idMap[canonicalKey];
317
+ return;
318
+ }
319
+ let deprecatedValue;
320
+ let hasDeprecatedValue = false;
321
+ for (const deprecatedKey of deprecatedKeys) {
322
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
323
+ if (!hasDeprecatedValue) {
324
+ deprecatedValue = idMap[deprecatedKey];
325
+ hasDeprecatedValue = true;
326
+ }
327
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
328
+ }
329
+ }
330
+ if (hasDeprecatedValue) {
331
+ resolved[canonicalKey] = deprecatedValue;
332
+ return;
333
+ }
334
+ resolved[canonicalKey] = defaults[canonicalKey];
335
+ }
336
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
337
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
338
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
339
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
340
+ this._reportWarning(
341
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
342
+ );
343
+ }
265
344
  _reportError(message, error = null) {
266
345
  const handler = this.options && this.options.onError;
267
346
  if (typeof handler !== "function") return;
@@ -278,6 +357,11 @@ var ImageEditor = class {
278
357
  } catch {
279
358
  }
280
359
  }
360
+ _notifyImageLoaded() {
361
+ const optionsCallback = this.options && this.options.onImageLoaded;
362
+ const callback = typeof optionsCallback === "function" ? optionsCallback : this.onImageLoaded;
363
+ if (typeof callback === "function") callback();
364
+ }
281
365
  /**
282
366
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
283
367
  *
@@ -300,7 +384,7 @@ var ImageEditor = class {
300
384
  } else {
301
385
  this.containerElement = canvasElement.parentElement;
302
386
  }
303
- this.placeholderElement = this._getElement("imgPlaceholder") || null;
387
+ this.placeholderElement = this._getElement("imagePlaceholder") || null;
304
388
  let initialWidth = this.options.canvasWidth;
305
389
  let initialHeight = this.options.canvasHeight;
306
390
  if (this.containerElement) {
@@ -450,20 +534,20 @@ var ImageEditor = class {
450
534
  });
451
535
  }
452
536
  });
453
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
454
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
455
- this._bindIfExists("resetBtn", "click", () => {
537
+ this._bindIfExists("zoomInButton", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
538
+ this._bindIfExists("zoomOutButton", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
539
+ this._bindIfExists("resetImageTransformButton", "click", () => {
456
540
  this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
457
541
  });
458
- this._bindIfExists("addMaskBtn", "click", () => this.createMask());
459
- this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
460
- this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
461
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
462
- this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
463
- this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
464
- this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
465
- this._bindIfExists("rotateLeftBtn", "click", () => {
466
- const rotationInputElement = this._getElement("rotationLeftInput");
542
+ this._bindIfExists("createMaskButton", "click", () => this.createMask());
543
+ this._bindIfExists("removeSelectedMaskButton", "click", () => this.removeSelectedMask());
544
+ this._bindIfExists("removeAllMasksButton", "click", () => this.removeAllMasks());
545
+ this._bindIfExists("mergeMasksButton", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
546
+ this._bindIfExists("downloadImageButton", "click", () => this.downloadImage());
547
+ this._bindIfExists("undoButton", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
548
+ this._bindIfExists("redoButton", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
549
+ this._bindIfExists("rotateLeftButton", "click", () => {
550
+ const rotationInputElement = this._getElement("rotateLeftDegreesInput");
467
551
  let step = this.options.rotationStep;
468
552
  if (rotationInputElement) {
469
553
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -471,8 +555,8 @@ var ImageEditor = class {
471
555
  }
472
556
  this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
473
557
  });
474
- this._bindIfExists("rotateRightBtn", "click", () => {
475
- const rotationInputElement = this._getElement("rotationRightInput");
558
+ this._bindIfExists("rotateRightButton", "click", () => {
559
+ const rotationInputElement = this._getElement("rotateRightDegreesInput");
476
560
  let step = this.options.rotationStep;
477
561
  if (rotationInputElement) {
478
562
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -480,11 +564,11 @@ var ImageEditor = class {
480
564
  }
481
565
  this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
482
566
  });
483
- this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
484
- this._bindIfExists("applyCropBtn", "click", () => {
567
+ this._bindIfExists("enterCropModeButton", "click", () => this.enterCropMode());
568
+ this._bindIfExists("applyCropButton", "click", () => {
485
569
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
486
570
  });
487
- this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
571
+ this._bindIfExists("cancelCropButton", "click", () => this.cancelCrop());
488
572
  this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
489
573
  }
490
574
  /**
@@ -654,9 +738,7 @@ var ImageEditor = class {
654
738
  this._updateUI();
655
739
  this.canvas.renderAll();
656
740
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
657
- if (typeof this.onImageLoaded === "function") {
658
- this.onImageLoaded();
659
- }
741
+ this._notifyImageLoaded();
660
742
  } catch (error) {
661
743
  await this._rollbackLoadImageTransaction(transaction);
662
744
  throw error;
@@ -709,7 +791,7 @@ var ImageEditor = class {
709
791
  try {
710
792
  imageElement.src = "";
711
793
  } catch (error) {
712
- void error;
794
+ this._reportWarning("Image timeout cleanup failed", error);
713
795
  }
714
796
  }, safeTimeoutMs);
715
797
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -777,6 +859,7 @@ var ImageEditor = class {
777
859
  async _rollbackLoadImageTransaction(transaction) {
778
860
  if (!transaction || !this.canvas || this._disposed) return;
779
861
  let didRestoreCanvasState = false;
862
+ let didFailCanvasRestore = false;
780
863
  try {
781
864
  if (transaction.canvasState) {
782
865
  await this.loadFromState(transaction.canvasState);
@@ -784,22 +867,27 @@ var ImageEditor = class {
784
867
  }
785
868
  } catch (error) {
786
869
  this._lastMask = null;
870
+ didFailCanvasRestore = true;
787
871
  this._reportError("loadImage rollback failed", error);
788
872
  }
789
- this.baseImageScale = transaction.baseImageScale;
790
- this.currentScale = transaction.currentScale;
791
- this.currentRotation = transaction.currentRotation;
792
- this.maskCounter = transaction.maskCounter;
793
- this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
794
- this._lastSnapshot = transaction.lastSnapshot;
795
- if (didRestoreCanvasState) {
796
- this._restoreLastMaskReference(transaction.lastMask);
873
+ if (didFailCanvasRestore) {
874
+ this._reconcileEditorStateFromCanvas();
797
875
  } else {
798
- this._lastMask = null;
876
+ this.baseImageScale = transaction.baseImageScale;
877
+ this.currentScale = transaction.currentScale;
878
+ this.currentRotation = transaction.currentRotation;
879
+ this.maskCounter = transaction.maskCounter;
880
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
881
+ this._lastSnapshot = transaction.lastSnapshot;
882
+ if (didRestoreCanvasState) {
883
+ this._restoreLastMaskReference(transaction.lastMask);
884
+ } else {
885
+ this._lastMask = null;
886
+ }
887
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
888
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
889
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
799
890
  }
800
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
801
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
802
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
803
891
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
804
892
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
805
893
  if (this.containerElement) {
@@ -812,6 +900,46 @@ var ImageEditor = class {
812
900
  this._updateUI();
813
901
  if (this.canvas) this.canvas.renderAll();
814
902
  }
903
+ _reconcileEditorStateFromCanvas() {
904
+ if (!this.canvas) {
905
+ this.originalImage = null;
906
+ this.baseImageScale = 1;
907
+ this.currentScale = 1;
908
+ this.currentRotation = 0;
909
+ this.maskCounter = 0;
910
+ this.isImageLoadedToCanvas = false;
911
+ this._lastSnapshot = null;
912
+ this._clearMaskPlacementMemory();
913
+ return;
914
+ }
915
+ const canvasObjects = this.canvas.getObjects();
916
+ this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
917
+ if (this.originalImage) {
918
+ const imageScale = Number(this.originalImage.scaleX) || 1;
919
+ this.baseImageScale = imageScale;
920
+ this.currentScale = 1;
921
+ this.currentRotation = Number(this.originalImage.angle) || 0;
922
+ } else {
923
+ this.baseImageScale = 1;
924
+ this.currentScale = 1;
925
+ this.currentRotation = 0;
926
+ }
927
+ const masks = canvasObjects.filter((object) => object.maskId);
928
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
929
+ this._lastMask = masks[masks.length - 1] || null;
930
+ if (!this._lastMask) {
931
+ this._lastMaskInitialLeft = null;
932
+ this._lastMaskInitialTop = null;
933
+ this._lastMaskInitialWidth = null;
934
+ }
935
+ this.isImageLoadedToCanvas = !!this.originalImage;
936
+ try {
937
+ this._lastSnapshot = this._serializeCanvasState();
938
+ } catch (error) {
939
+ this._lastSnapshot = null;
940
+ this._reportWarning("loadImage rollback: failed to reconcile canvas snapshot", error);
941
+ }
942
+ }
815
943
  _restoreLastMaskReference(previousLastMask) {
816
944
  if (!this.canvas) {
817
945
  this._lastMask = null;
@@ -882,6 +1010,7 @@ var ImageEditor = class {
882
1010
  * @private
883
1011
  */
884
1012
  _setCanvasSizeInt(width, height) {
1013
+ if (!this.canvas) return;
885
1014
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
886
1015
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
887
1016
  this.canvas.setWidth(integerWidth);
@@ -1154,7 +1283,7 @@ var ImageEditor = class {
1154
1283
  /**
1155
1284
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1156
1285
  *
1157
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1286
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1158
1287
  * @private
1159
1288
  */
1160
1289
  _serializeEditorMetadata() {
@@ -1162,12 +1291,16 @@ var ImageEditor = class {
1162
1291
  const currentScale = Number(this.currentScale);
1163
1292
  const currentRotation = Number(this.currentRotation);
1164
1293
  const maskCounter = Number(this.maskCounter);
1294
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1295
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1165
1296
  return {
1166
1297
  version: 1,
1167
1298
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1168
1299
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1169
1300
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1170
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1301
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1302
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1303
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1171
1304
  };
1172
1305
  }
1173
1306
  _serializeCanvasState() {
@@ -1503,17 +1636,13 @@ var ImageEditor = class {
1503
1636
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1504
1637
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1505
1638
  });
1506
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1507
1639
  let minWidth = 0;
1508
1640
  let minHeight = 0;
1509
- if (shouldUseScrollSafeViewport) {
1641
+ if (this.containerElement) {
1510
1642
  const viewport = this._getContainerViewportSize();
1511
1643
  const safetyMargin = this._getScrollSafetyMargin();
1512
1644
  minWidth = Math.max(1, viewport.width - safetyMargin);
1513
1645
  minHeight = Math.max(1, viewport.height - safetyMargin);
1514
- } else if (this.containerElement) {
1515
- minWidth = Math.floor(this.containerElement.clientWidth || 0);
1516
- minHeight = Math.floor(this.containerElement.clientHeight || 0);
1517
1646
  }
1518
1647
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1519
1648
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1582,9 +1711,15 @@ var ImageEditor = class {
1582
1711
  _assertEditorAvailable(operationName) {
1583
1712
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1584
1713
  }
1714
+ _isCropModeAllowedOperation(operationName) {
1715
+ return operationName === "applyCrop" || operationName === "cancelCrop";
1716
+ }
1585
1717
  _assertIdleForOperation(operationName, options = {}) {
1586
1718
  this._assertEditorAvailable(operationName);
1587
1719
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
1720
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1721
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1722
+ }
1588
1723
  if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1589
1724
  throw new Error(`${operationName} cannot run while an animation is running`);
1590
1725
  }
@@ -1597,10 +1732,14 @@ var ImageEditor = class {
1597
1732
  }
1598
1733
  _assertCanQueueAnimation(operationName, options = {}) {
1599
1734
  this._assertEditorAvailable(operationName);
1600
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
1735
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1736
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1737
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1738
+ }
1739
+ if (this._isLoading && !isOwnInternalOperation) {
1601
1740
  throw new Error(`${operationName} cannot run while an image is loading`);
1602
1741
  }
1603
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1742
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1604
1743
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1605
1744
  }
1606
1745
  }
@@ -1787,10 +1926,19 @@ var ImageEditor = class {
1787
1926
  }
1788
1927
  return this.animationQueue.add(async () => {
1789
1928
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1790
- await this._scaleImageImpl(1, { saveHistory: false });
1791
- await this._rotateImageImpl(0, { saveHistory: false });
1792
- const after = this._captureCanvasStateOrThrow("resetImageTransform");
1793
- this._pushStateTransition(before, after);
1929
+ try {
1930
+ await this._scaleImageImpl(1, { saveHistory: false });
1931
+ await this._rotateImageImpl(0, { saveHistory: false });
1932
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1933
+ this._pushStateTransition(before, after);
1934
+ } catch (error) {
1935
+ try {
1936
+ await this.loadFromState(before);
1937
+ } catch (restoreError) {
1938
+ this._reportError("resetImageTransform rollback failed", restoreError);
1939
+ }
1940
+ throw error;
1941
+ }
1794
1942
  }).finally(() => {
1795
1943
  if (!this._disposed && this.canvas) this._updateUI();
1796
1944
  }).catch((error) => {
@@ -1829,10 +1977,13 @@ var ImageEditor = class {
1829
1977
  try {
1830
1978
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1831
1979
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1980
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
1981
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
1982
+ const hasRestoredCanvasSize = Number.isFinite(restoredCanvasWidth) && restoredCanvasWidth > 0 && Number.isFinite(restoredCanvasHeight) && restoredCanvasHeight > 0;
1832
1983
  if (editorMetadata && Object.prototype.hasOwnProperty.call(editorMetadata, "version") && Number(editorMetadata.version) !== 1) {
1833
1984
  this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
1834
1985
  }
1835
- this.canvas.loadFromJSON(state, async () => {
1986
+ const finishLoad = async () => {
1836
1987
  try {
1837
1988
  if (this._disposed || !this.canvas) {
1838
1989
  reject(new Error("Editor was disposed while loading state"));
@@ -1868,6 +2019,11 @@ var ImageEditor = class {
1868
2019
  this.currentScale = 1;
1869
2020
  this.currentRotation = 0;
1870
2021
  }
2022
+ if (hasRestoredCanvasSize) {
2023
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2024
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2025
+ this._updateCanvasSizeToImageBounds();
2026
+ }
1871
2027
  const masks = canvasObjects.filter((object) => object.maskId);
1872
2028
  masks.forEach((mask) => {
1873
2029
  this._restoreMaskControls(mask);
@@ -1895,6 +2051,9 @@ var ImageEditor = class {
1895
2051
  this._reportError("loadFromState() failed", callbackError);
1896
2052
  reject(callbackError);
1897
2053
  }
2054
+ };
2055
+ this.canvas.loadFromJSON(state, () => {
2056
+ void finishLoad();
1898
2057
  });
1899
2058
  } catch (error) {
1900
2059
  this._reportError("loadFromState() failed", error);
@@ -2042,14 +2201,7 @@ var ImageEditor = class {
2042
2201
  }
2043
2202
  _rebindMaskEvents(mask) {
2044
2203
  if (!mask) return;
2045
- if (mask.__imageEditorMaskHandlers) {
2046
- try {
2047
- mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2048
- mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2049
- } catch (error) {
2050
- void error;
2051
- }
2052
- }
2204
+ this._cleanupMaskEvents(mask);
2053
2205
  const metadata = {};
2054
2206
  if (!Number.isFinite(Number(mask.originalAlpha))) {
2055
2207
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
@@ -2076,6 +2228,22 @@ var ImageEditor = class {
2076
2228
  mask.on("mouseout", mouseout);
2077
2229
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2078
2230
  }
2231
+ _cleanupMaskEvents(mask) {
2232
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2233
+ try {
2234
+ if (typeof mask.off === "function") {
2235
+ mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2236
+ mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2237
+ }
2238
+ } catch (error) {
2239
+ this._reportWarning("Mask event cleanup failed", error);
2240
+ }
2241
+ try {
2242
+ delete mask.__imageEditorMaskHandlers;
2243
+ } catch (error) {
2244
+ this._reportWarning("Mask event metadata cleanup failed", error);
2245
+ }
2246
+ }
2079
2247
  /**
2080
2248
  * Creates a mask and adds it to the canvas.
2081
2249
  *
@@ -2286,6 +2454,7 @@ var ImageEditor = class {
2286
2454
  this.canvas.discardActiveObject();
2287
2455
  selectedMasks.forEach((mask) => {
2288
2456
  this._removeLabelForMask(mask);
2457
+ this._cleanupMaskEvents(mask);
2289
2458
  this.canvas.remove(mask);
2290
2459
  });
2291
2460
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -2310,7 +2479,10 @@ var ImageEditor = class {
2310
2479
  const saveHistory = options.saveHistory !== false;
2311
2480
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2312
2481
  masks.forEach((mask) => this._removeLabelForMask(mask));
2313
- masks.forEach((mask) => this.canvas.remove(mask));
2482
+ masks.forEach((mask) => {
2483
+ this._cleanupMaskEvents(mask);
2484
+ this.canvas.remove(mask);
2485
+ });
2314
2486
  this.canvas.discardActiveObject();
2315
2487
  this._lastMask = null;
2316
2488
  this._lastMaskInitialLeft = null;
@@ -2379,7 +2551,7 @@ var ImageEditor = class {
2379
2551
  if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2380
2552
  this._syncMaskLabel(backup.mask);
2381
2553
  } catch (error) {
2382
- void error;
2554
+ this._reportWarning("restoreMaskLabelBackups: failed to restore mask label", error);
2383
2555
  }
2384
2556
  });
2385
2557
  }
@@ -2510,7 +2682,6 @@ var ImageEditor = class {
2510
2682
  try {
2511
2683
  if (canvasObjectSet.has(label)) {
2512
2684
  this.canvas.remove(label);
2513
- canvasObjectSet.delete(label);
2514
2685
  }
2515
2686
  } catch (error) {
2516
2687
  void error;
@@ -2796,7 +2967,6 @@ var ImageEditor = class {
2796
2967
  const maskStyleBackups = this._captureMaskExportBackups(masks);
2797
2968
  const labelBackups = this._captureMaskLabelBackups(masks);
2798
2969
  const activeObjectBackup = this._captureActiveObjectBackup();
2799
- let finalBase64;
2800
2970
  try {
2801
2971
  masks.forEach((mask) => this._removeLabelForMask(mask));
2802
2972
  this.canvas.discardActiveObject();
@@ -2809,7 +2979,7 @@ var ImageEditor = class {
2809
2979
  this.originalImage.setCoords();
2810
2980
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2811
2981
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
2812
- finalBase64 = await this._exportCanvasRegionToDataURL({
2982
+ return await this._exportCanvasRegionToDataURL({
2813
2983
  ...exportRegion,
2814
2984
  multiplier,
2815
2985
  quality,
@@ -2822,7 +2992,6 @@ var ImageEditor = class {
2822
2992
  this._restoreActiveObjectBackup(activeObjectBackup);
2823
2993
  this.canvas.renderAll();
2824
2994
  }
2825
- return finalBase64;
2826
2995
  }
2827
2996
  /**
2828
2997
  * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
@@ -2949,22 +3118,21 @@ var ImageEditor = class {
2949
3118
  this._cropPrevEvented = null;
2950
3119
  }
2951
3120
  _removeCropRect() {
2952
- if (!this._cropRect) return;
2953
- try {
2954
- if (this._cropHandlers && this._cropHandlers.length) {
2955
- this._cropHandlers.forEach((targetHandlers) => {
2956
- targetHandlers.handlers.forEach((handlerRecord) => {
3121
+ if (this._cropHandlers && this._cropHandlers.length) {
3122
+ this._cropHandlers.forEach((targetHandlers) => {
3123
+ (targetHandlers.handlers || []).forEach((handlerRecord) => {
3124
+ try {
2957
3125
  if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2958
3126
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2959
3127
  }
2960
- });
3128
+ } catch (error) {
3129
+ this._reportWarning("Crop handler cleanup failed", error);
3130
+ }
2961
3131
  });
2962
- }
2963
- } catch (error) {
2964
- void error;
3132
+ });
2965
3133
  }
2966
3134
  try {
2967
- if (this.canvas) this.canvas.remove(this._cropRect);
3135
+ if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect);
2968
3136
  } catch (error) {
2969
3137
  void error;
2970
3138
  }
@@ -3134,9 +3302,13 @@ var ImageEditor = class {
3134
3302
  try {
3135
3303
  beforeJson = this._serializeCanvasState();
3136
3304
  } catch (error) {
3137
- this._reportWarning("applyCrop: could not serialize before state", error);
3305
+ this._reportError("applyCrop: failed to capture rollback state", error);
3138
3306
  beforeJson = null;
3139
3307
  }
3308
+ if (!beforeJson) {
3309
+ this.cancelCrop();
3310
+ return;
3311
+ }
3140
3312
  const preservedMasks = [];
3141
3313
  try {
3142
3314
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -3146,6 +3318,7 @@ var ImageEditor = class {
3146
3318
  const maskBounds = mask.getBoundingRect(true, true);
3147
3319
  const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3148
3320
  this._removeLabelForMask(mask);
3321
+ this._cleanupMaskEvents(mask);
3149
3322
  this.canvas.remove(mask);
3150
3323
  if (shouldPreserveMasks && intersectsCrop) {
3151
3324
  this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
@@ -3216,7 +3389,7 @@ var ImageEditor = class {
3216
3389
  * @private
3217
3390
  */
3218
3391
  _updateInputs() {
3219
- const scaleInputElement = this._getElement("scaleRate");
3392
+ const scaleInputElement = this._getElement("scalePercentageInput");
3220
3393
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3221
3394
  }
3222
3395
  /**
@@ -3240,7 +3413,7 @@ var ImageEditor = class {
3240
3413
  for (const key of Object.keys(this.elements || {})) {
3241
3414
  const element = this._getElement(key);
3242
3415
  if (!element) continue;
3243
- if (key === "applyCropBtn" || key === "cancelCropBtn") {
3416
+ if (key === "applyCropButton" || key === "cancelCropButton" || key === "applyCropBtn" || key === "cancelCropBtn") {
3244
3417
  this._setDisabled(key, false);
3245
3418
  } else {
3246
3419
  this._setDisabled(key, true);
@@ -3248,24 +3421,24 @@ var ImageEditor = class {
3248
3421
  }
3249
3422
  return;
3250
3423
  }
3251
- this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3252
- this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3253
- this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3254
- this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3255
- this._setDisabled("addMaskBtn", !hasImage || isBusy);
3256
- this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3257
- this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3258
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3259
- this._setDisabled("downloadBtn", !hasImage || isBusy);
3260
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3261
- this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3262
- this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3263
- this._setDisabled("cropBtn", !hasImage || isBusy);
3264
- this._setDisabled("applyCropBtn", true);
3265
- this._setDisabled("cancelCropBtn", true);
3266
- this._setDisabled("scaleRate", !hasImage || isBusy);
3267
- this._setDisabled("rotationLeftInput", !hasImage || isBusy);
3268
- this._setDisabled("rotationRightInput", !hasImage || isBusy);
3424
+ this._setDisabled("zoomInButton", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3425
+ this._setDisabled("zoomOutButton", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3426
+ this._setDisabled("rotateLeftButton", !hasImage || isBusy);
3427
+ this._setDisabled("rotateRightButton", !hasImage || isBusy);
3428
+ this._setDisabled("createMaskButton", !hasImage || isBusy);
3429
+ this._setDisabled("removeSelectedMaskButton", !hasSelectedMask || isBusy);
3430
+ this._setDisabled("removeAllMasksButton", !hasMasks || isBusy);
3431
+ this._setDisabled("mergeMasksButton", !hasImage || !hasMasks || isBusy);
3432
+ this._setDisabled("downloadImageButton", !hasImage || isBusy);
3433
+ this._setDisabled("resetImageTransformButton", !hasImage || isDefaultTransform || isBusy);
3434
+ this._setDisabled("undoButton", !hasImage || isBusy || !canUndo);
3435
+ this._setDisabled("redoButton", !hasImage || isBusy || !canRedo);
3436
+ this._setDisabled("enterCropModeButton", !hasImage || isBusy);
3437
+ this._setDisabled("applyCropButton", true);
3438
+ this._setDisabled("cancelCropButton", true);
3439
+ this._setDisabled("scalePercentageInput", !hasImage || isBusy);
3440
+ this._setDisabled("rotateLeftDegreesInput", !hasImage || isBusy);
3441
+ this._setDisabled("rotateRightDegreesInput", !hasImage || isBusy);
3269
3442
  this._setDisabled("maskList", !hasImage || isBusy);
3270
3443
  this._setDisabled("imageInput", isBusy);
3271
3444
  this._setDisabled("uploadArea", isBusy);
@@ -3273,7 +3446,7 @@ var ImageEditor = class {
3273
3446
  /**
3274
3447
  * Enables or disables a specific UI element (typically a button) by its key.
3275
3448
  *
3276
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
3449
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3277
3450
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3278
3451
  * @private
3279
3452
  */
@@ -3397,14 +3570,7 @@ var ImageEditor = class {
3397
3570
  } catch (error) {
3398
3571
  void error;
3399
3572
  }
3400
- if (this._cropRect) {
3401
- try {
3402
- this.canvas.remove(this._cropRect);
3403
- } catch (error) {
3404
- void error;
3405
- }
3406
- this._cropRect = null;
3407
- }
3573
+ if (this._cropRect) this._removeCropRect();
3408
3574
  if (this.containerElement && this._containerOriginalOverflow) {
3409
3575
  try {
3410
3576
  this._restoreContainerOverflowState();
@@ -3427,11 +3593,19 @@ var ImageEditor = class {
3427
3593
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3428
3594
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3429
3595
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3596
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3430
3597
  } catch (error) {
3431
3598
  void error;
3432
3599
  }
3433
3600
  }
3434
3601
  if (this.canvas) {
3602
+ try {
3603
+ this.canvas.getObjects().forEach((object) => {
3604
+ if (object && object.maskId) this._cleanupMaskEvents(object);
3605
+ });
3606
+ } catch (error) {
3607
+ void error;
3608
+ }
3435
3609
  try {
3436
3610
  this.canvas.dispose();
3437
3611
  } catch (error) {
@@ -3528,7 +3702,7 @@ var AnimationQueue = class {
3528
3702
  task.reject(error);
3529
3703
  }
3530
3704
  } finally {
3531
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3705
+ if (this.currentTask === task) this.currentTask = null;
3532
3706
  }
3533
3707
  }
3534
3708
  } finally {