@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.
@@ -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.0
7
7
  * @author Ben Situ
8
8
  * @license MIT
9
9
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -144,7 +144,7 @@
144
144
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
145
145
  this._disposed = false;
146
146
  this._initialized = false;
147
- this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
147
+ this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
148
148
  this.animationQueue = new AnimationQueue();
149
149
  this.historyManager = new HistoryManager(this.maxHistorySize);
150
150
  }
@@ -190,10 +190,12 @@
190
190
  * Use this method to set up the editor UI before interacting with it.
191
191
  *
192
192
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
193
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
194
- * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
195
- * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
196
- * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
193
+ * Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
194
+ * rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
195
+ * createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
196
+ * downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
197
+ * undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
198
+ * and cancelCropButton. Deprecated 1.x names remain supported as aliases.
197
199
  *
198
200
  * @returns {void}
199
201
  *
@@ -202,7 +204,7 @@
202
204
  * @example
203
205
  * editor.init({
204
206
  * canvas: 'myFabricCanvasId',
205
- * downloadBtn: 'myDownloadButtonId'
207
+ * downloadImageButton: 'myDownloadButtonId'
206
208
  * });
207
209
  */
208
210
  init(idMap = {}) {
@@ -221,33 +223,53 @@
221
223
  this._containerOriginalOverflow = null;
222
224
  this._lastContainerViewportSize = null;
223
225
  this._canvasElementOriginalStyle = null;
226
+ this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
224
227
  const defaults = {
225
228
  canvas: "fabricCanvas",
226
229
  canvasContainer: null,
227
230
  // Pass an ID here if you have a scrollable viewport container
228
- imgPlaceholder: "imgPlaceholder",
229
- scaleRate: "scaleRate",
230
- rotationLeftInput: "rotationLeftInput",
231
- rotationRightInput: "rotationRightInput",
232
- rotateLeftBtn: "rotateLeftBtn",
233
- rotateRightBtn: "rotateRightBtn",
234
- addMaskBtn: "addMaskBtn",
235
- removeMaskBtn: "removeMaskBtn",
236
- removeAllMasksBtn: "removeAllMasksBtn",
237
- mergeBtn: "mergeBtn",
238
- downloadBtn: "downloadBtn",
231
+ imagePlaceholder: "imagePlaceholder",
232
+ imgPlaceholder: null,
233
+ scalePercentageInput: "scalePercentageInput",
234
+ scaleRate: null,
235
+ rotateLeftDegreesInput: "rotateLeftDegreesInput",
236
+ rotationLeftInput: null,
237
+ rotateRightDegreesInput: "rotateRightDegreesInput",
238
+ rotationRightInput: null,
239
+ rotateLeftButton: "rotateLeftButton",
240
+ rotateLeftBtn: null,
241
+ rotateRightButton: "rotateRightButton",
242
+ rotateRightBtn: null,
243
+ createMaskButton: "createMaskButton",
244
+ addMaskBtn: null,
245
+ removeSelectedMaskButton: "removeSelectedMaskButton",
246
+ removeMaskBtn: null,
247
+ removeAllMasksButton: "removeAllMasksButton",
248
+ removeAllMasksBtn: null,
249
+ mergeMasksButton: "mergeMasksButton",
250
+ mergeBtn: null,
251
+ downloadImageButton: "downloadImageButton",
252
+ downloadBtn: null,
239
253
  maskList: "maskList",
240
- zoomInBtn: "zoomInBtn",
241
- zoomOutBtn: "zoomOutBtn",
242
- resetBtn: "resetBtn",
243
- undoBtn: "undoBtn",
244
- redoBtn: "redoBtn",
254
+ zoomInButton: "zoomInButton",
255
+ zoomInBtn: null,
256
+ zoomOutButton: "zoomOutButton",
257
+ zoomOutBtn: null,
258
+ resetImageTransformButton: "resetImageTransformButton",
259
+ resetBtn: null,
260
+ undoButton: "undoButton",
261
+ undoBtn: null,
262
+ redoButton: "redoButton",
263
+ redoBtn: null,
245
264
  imageInput: "imageInput",
246
- cropBtn: "cropBtn",
247
- applyCropBtn: "applyCropBtn",
248
- cancelCropBtn: "cancelCropBtn"
265
+ enterCropModeButton: "enterCropModeButton",
266
+ cropBtn: null,
267
+ applyCropButton: "applyCropButton",
268
+ applyCropBtn: null,
269
+ cancelCropButton: "cancelCropButton",
270
+ cancelCropBtn: null
249
271
  };
250
- this.elements = { ...defaults, ...idMap };
272
+ this.elements = this._resolveElementIdMap(idMap || {}, defaults);
251
273
  this._elementCache = {};
252
274
  this._initCanvas();
253
275
  this._bindEvents();
@@ -260,6 +282,63 @@
260
282
  this._updatePlaceholderStatus();
261
283
  }
262
284
  }
285
+ _resolveElementIdMap(idMap, defaults) {
286
+ const resolved = { ...defaults, ...idMap };
287
+ this._resolveElementAliases(resolved, idMap, defaults, "imagePlaceholder", ["imgPlaceholder"]);
288
+ this._resolveElementAliases(resolved, idMap, defaults, "scalePercentageInput", ["scaleRate"]);
289
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateLeftDegreesInput", ["rotationLeftInput"]);
290
+ this._resolveElementAliases(resolved, idMap, defaults, "rotateRightDegreesInput", ["rotationRightInput"]);
291
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateLeftButton", "rotateLeftBtn");
292
+ this._resolveElementAlias(resolved, idMap, defaults, "rotateRightButton", "rotateRightBtn");
293
+ this._resolveElementAlias(resolved, idMap, defaults, "createMaskButton", "addMaskBtn");
294
+ this._resolveElementAliases(resolved, idMap, defaults, "removeSelectedMaskButton", ["removeMaskBtn"]);
295
+ this._resolveElementAlias(resolved, idMap, defaults, "removeAllMasksButton", "removeAllMasksBtn");
296
+ this._resolveElementAlias(resolved, idMap, defaults, "mergeMasksButton", "mergeBtn");
297
+ this._resolveElementAliases(resolved, idMap, defaults, "downloadImageButton", ["downloadBtn"]);
298
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomInButton", "zoomInBtn");
299
+ this._resolveElementAlias(resolved, idMap, defaults, "zoomOutButton", "zoomOutBtn");
300
+ this._resolveElementAlias(resolved, idMap, defaults, "resetImageTransformButton", "resetBtn");
301
+ this._resolveElementAlias(resolved, idMap, defaults, "undoButton", "undoBtn");
302
+ this._resolveElementAlias(resolved, idMap, defaults, "redoButton", "redoBtn");
303
+ this._resolveElementAliases(resolved, idMap, defaults, "enterCropModeButton", ["cropBtn"]);
304
+ this._resolveElementAlias(resolved, idMap, defaults, "applyCropButton", "applyCropBtn");
305
+ this._resolveElementAlias(resolved, idMap, defaults, "cancelCropButton", "cancelCropBtn");
306
+ return resolved;
307
+ }
308
+ _resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
309
+ this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
310
+ }
311
+ _resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
312
+ const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
313
+ if (hasCanonicalKey) {
314
+ resolved[canonicalKey] = idMap[canonicalKey];
315
+ return;
316
+ }
317
+ let deprecatedValue;
318
+ let hasDeprecatedValue = false;
319
+ for (const deprecatedKey of deprecatedKeys) {
320
+ if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
321
+ if (!hasDeprecatedValue) {
322
+ deprecatedValue = idMap[deprecatedKey];
323
+ hasDeprecatedValue = true;
324
+ }
325
+ this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
326
+ }
327
+ }
328
+ if (hasDeprecatedValue) {
329
+ resolved[canonicalKey] = deprecatedValue;
330
+ return;
331
+ }
332
+ resolved[canonicalKey] = defaults[canonicalKey];
333
+ }
334
+ _warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
335
+ if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
336
+ if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
337
+ this._deprecatedElementKeyWarnings.add(deprecatedKey);
338
+ this._reportWarning(
339
+ `ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
340
+ );
341
+ }
263
342
  _reportError(message, error = null) {
264
343
  const handler = this.options && this.options.onError;
265
344
  if (typeof handler !== "function") return;
@@ -276,6 +355,11 @@
276
355
  } catch {
277
356
  }
278
357
  }
358
+ _notifyImageLoaded() {
359
+ const optionsCallback = this.options && this.options.onImageLoaded;
360
+ const callback = typeof optionsCallback === "function" ? optionsCallback : this.onImageLoaded;
361
+ if (typeof callback === "function") callback();
362
+ }
279
363
  /**
280
364
  * Initializes the Fabric canvas, viewport elements, and selection event handlers.
281
365
  *
@@ -298,7 +382,7 @@
298
382
  } else {
299
383
  this.containerElement = canvasElement.parentElement;
300
384
  }
301
- this.placeholderElement = this._getElement("imgPlaceholder") || null;
385
+ this.placeholderElement = this._getElement("imagePlaceholder") || null;
302
386
  let initialWidth = this.options.canvasWidth;
303
387
  let initialHeight = this.options.canvasHeight;
304
388
  if (this.containerElement) {
@@ -448,20 +532,20 @@
448
532
  });
449
533
  }
450
534
  });
451
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
452
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
453
- this._bindIfExists("resetBtn", "click", () => {
535
+ this._bindIfExists("zoomInButton", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
536
+ this._bindIfExists("zoomOutButton", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
537
+ this._bindIfExists("resetImageTransformButton", "click", () => {
454
538
  this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
455
539
  });
456
- this._bindIfExists("addMaskBtn", "click", () => this.createMask());
457
- this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
458
- this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
459
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
460
- this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
461
- this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
462
- this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
463
- this._bindIfExists("rotateLeftBtn", "click", () => {
464
- const rotationInputElement = this._getElement("rotationLeftInput");
540
+ this._bindIfExists("createMaskButton", "click", () => this.createMask());
541
+ this._bindIfExists("removeSelectedMaskButton", "click", () => this.removeSelectedMask());
542
+ this._bindIfExists("removeAllMasksButton", "click", () => this.removeAllMasks());
543
+ this._bindIfExists("mergeMasksButton", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
544
+ this._bindIfExists("downloadImageButton", "click", () => this.downloadImage());
545
+ this._bindIfExists("undoButton", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
546
+ this._bindIfExists("redoButton", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
547
+ this._bindIfExists("rotateLeftButton", "click", () => {
548
+ const rotationInputElement = this._getElement("rotateLeftDegreesInput");
465
549
  let step = this.options.rotationStep;
466
550
  if (rotationInputElement) {
467
551
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -469,8 +553,8 @@
469
553
  }
470
554
  this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
471
555
  });
472
- this._bindIfExists("rotateRightBtn", "click", () => {
473
- const rotationInputElement = this._getElement("rotationRightInput");
556
+ this._bindIfExists("rotateRightButton", "click", () => {
557
+ const rotationInputElement = this._getElement("rotateRightDegreesInput");
474
558
  let step = this.options.rotationStep;
475
559
  if (rotationInputElement) {
476
560
  const parsedStep = parseFloat(rotationInputElement.value);
@@ -478,11 +562,11 @@
478
562
  }
479
563
  this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
480
564
  });
481
- this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
482
- this._bindIfExists("applyCropBtn", "click", () => {
565
+ this._bindIfExists("enterCropModeButton", "click", () => this.enterCropMode());
566
+ this._bindIfExists("applyCropButton", "click", () => {
483
567
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
484
568
  });
485
- this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
569
+ this._bindIfExists("cancelCropButton", "click", () => this.cancelCrop());
486
570
  this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
487
571
  }
488
572
  /**
@@ -652,9 +736,7 @@
652
736
  this._updateUI();
653
737
  this.canvas.renderAll();
654
738
  this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
655
- if (typeof this.onImageLoaded === "function") {
656
- this.onImageLoaded();
657
- }
739
+ this._notifyImageLoaded();
658
740
  } catch (error) {
659
741
  await this._rollbackLoadImageTransaction(transaction);
660
742
  throw error;
@@ -707,7 +789,7 @@
707
789
  try {
708
790
  imageElement.src = "";
709
791
  } catch (error) {
710
- void error;
792
+ this._reportWarning("Image timeout cleanup failed", error);
711
793
  }
712
794
  }, safeTimeoutMs);
713
795
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -775,6 +857,7 @@
775
857
  async _rollbackLoadImageTransaction(transaction) {
776
858
  if (!transaction || !this.canvas || this._disposed) return;
777
859
  let didRestoreCanvasState = false;
860
+ let didFailCanvasRestore = false;
778
861
  try {
779
862
  if (transaction.canvasState) {
780
863
  await this.loadFromState(transaction.canvasState);
@@ -782,22 +865,27 @@
782
865
  }
783
866
  } catch (error) {
784
867
  this._lastMask = null;
868
+ didFailCanvasRestore = true;
785
869
  this._reportError("loadImage rollback failed", error);
786
870
  }
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);
871
+ if (didFailCanvasRestore) {
872
+ this._reconcileEditorStateFromCanvas();
795
873
  } else {
796
- this._lastMask = null;
874
+ this.baseImageScale = transaction.baseImageScale;
875
+ this.currentScale = transaction.currentScale;
876
+ this.currentRotation = transaction.currentRotation;
877
+ this.maskCounter = transaction.maskCounter;
878
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
879
+ this._lastSnapshot = transaction.lastSnapshot;
880
+ if (didRestoreCanvasState) {
881
+ this._restoreLastMaskReference(transaction.lastMask);
882
+ } else {
883
+ this._lastMask = null;
884
+ }
885
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
886
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
887
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
797
888
  }
798
- this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
799
- this._lastMaskInitialTop = transaction.lastMaskInitialTop;
800
- this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
801
889
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
802
890
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
803
891
  if (this.containerElement) {
@@ -810,6 +898,46 @@
810
898
  this._updateUI();
811
899
  if (this.canvas) this.canvas.renderAll();
812
900
  }
901
+ _reconcileEditorStateFromCanvas() {
902
+ if (!this.canvas) {
903
+ this.originalImage = null;
904
+ this.baseImageScale = 1;
905
+ this.currentScale = 1;
906
+ this.currentRotation = 0;
907
+ this.maskCounter = 0;
908
+ this.isImageLoadedToCanvas = false;
909
+ this._lastSnapshot = null;
910
+ this._clearMaskPlacementMemory();
911
+ return;
912
+ }
913
+ const canvasObjects = this.canvas.getObjects();
914
+ this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
915
+ if (this.originalImage) {
916
+ const imageScale = Number(this.originalImage.scaleX) || 1;
917
+ this.baseImageScale = imageScale;
918
+ this.currentScale = 1;
919
+ this.currentRotation = Number(this.originalImage.angle) || 0;
920
+ } else {
921
+ this.baseImageScale = 1;
922
+ this.currentScale = 1;
923
+ this.currentRotation = 0;
924
+ }
925
+ const masks = canvasObjects.filter((object) => object.maskId);
926
+ this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
927
+ this._lastMask = masks[masks.length - 1] || null;
928
+ if (!this._lastMask) {
929
+ this._lastMaskInitialLeft = null;
930
+ this._lastMaskInitialTop = null;
931
+ this._lastMaskInitialWidth = null;
932
+ }
933
+ this.isImageLoadedToCanvas = !!this.originalImage;
934
+ try {
935
+ this._lastSnapshot = this._serializeCanvasState();
936
+ } catch (error) {
937
+ this._lastSnapshot = null;
938
+ this._reportWarning("loadImage rollback: failed to reconcile canvas snapshot", error);
939
+ }
940
+ }
813
941
  _restoreLastMaskReference(previousLastMask) {
814
942
  if (!this.canvas) {
815
943
  this._lastMask = null;
@@ -880,6 +1008,7 @@
880
1008
  * @private
881
1009
  */
882
1010
  _setCanvasSizeInt(width, height) {
1011
+ if (!this.canvas) return;
883
1012
  const integerWidth = Math.max(1, Math.round(Number(width) || 1));
884
1013
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
885
1014
  this.canvas.setWidth(integerWidth);
@@ -1152,7 +1281,7 @@
1152
1281
  /**
1153
1282
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1154
1283
  *
1155
- * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1284
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
1156
1285
  * @private
1157
1286
  */
1158
1287
  _serializeEditorMetadata() {
@@ -1160,12 +1289,16 @@
1160
1289
  const currentScale = Number(this.currentScale);
1161
1290
  const currentRotation = Number(this.currentRotation);
1162
1291
  const maskCounter = Number(this.maskCounter);
1292
+ const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
1293
+ const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
1163
1294
  return {
1164
1295
  version: 1,
1165
1296
  baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1166
1297
  currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1167
1298
  currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1168
- maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1299
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
1300
+ canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
1301
+ canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
1169
1302
  };
1170
1303
  }
1171
1304
  _serializeCanvasState() {
@@ -1501,17 +1634,13 @@
1501
1634
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1502
1635
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1503
1636
  });
1504
- const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1505
1637
  let minWidth = 0;
1506
1638
  let minHeight = 0;
1507
- if (shouldUseScrollSafeViewport) {
1639
+ if (this.containerElement) {
1508
1640
  const viewport = this._getContainerViewportSize();
1509
1641
  const safetyMargin = this._getScrollSafetyMargin();
1510
1642
  minWidth = Math.max(1, viewport.width - safetyMargin);
1511
1643
  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
1644
  }
1516
1645
  const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1517
1646
  const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
@@ -1580,9 +1709,15 @@
1580
1709
  _assertEditorAvailable(operationName) {
1581
1710
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1582
1711
  }
1712
+ _isCropModeAllowedOperation(operationName) {
1713
+ return operationName === "applyCrop" || operationName === "cancelCrop";
1714
+ }
1583
1715
  _assertIdleForOperation(operationName, options = {}) {
1584
1716
  this._assertEditorAvailable(operationName);
1585
1717
  const isOwnInternalOperation = this._isOwnInternalOperation(options);
1718
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1719
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1720
+ }
1586
1721
  if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1587
1722
  throw new Error(`${operationName} cannot run while an animation is running`);
1588
1723
  }
@@ -1595,10 +1730,14 @@
1595
1730
  }
1596
1731
  _assertCanQueueAnimation(operationName, options = {}) {
1597
1732
  this._assertEditorAvailable(operationName);
1598
- if (this._isLoading && !this._isOwnInternalOperation(options)) {
1733
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1734
+ if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
1735
+ throw new Error(`${operationName} cannot run while crop mode is active`);
1736
+ }
1737
+ if (this._isLoading && !isOwnInternalOperation) {
1599
1738
  throw new Error(`${operationName} cannot run while an image is loading`);
1600
1739
  }
1601
- if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1740
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1602
1741
  throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1603
1742
  }
1604
1743
  }
@@ -1785,10 +1924,19 @@
1785
1924
  }
1786
1925
  return this.animationQueue.add(async () => {
1787
1926
  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);
1927
+ try {
1928
+ await this._scaleImageImpl(1, { saveHistory: false });
1929
+ await this._rotateImageImpl(0, { saveHistory: false });
1930
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1931
+ this._pushStateTransition(before, after);
1932
+ } catch (error) {
1933
+ try {
1934
+ await this.loadFromState(before);
1935
+ } catch (restoreError) {
1936
+ this._reportError("resetImageTransform rollback failed", restoreError);
1937
+ }
1938
+ throw error;
1939
+ }
1792
1940
  }).finally(() => {
1793
1941
  if (!this._disposed && this.canvas) this._updateUI();
1794
1942
  }).catch((error) => {
@@ -1827,10 +1975,13 @@
1827
1975
  try {
1828
1976
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1829
1977
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1978
+ const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
1979
+ const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
1980
+ const hasRestoredCanvasSize = Number.isFinite(restoredCanvasWidth) && restoredCanvasWidth > 0 && Number.isFinite(restoredCanvasHeight) && restoredCanvasHeight > 0;
1830
1981
  if (editorMetadata && Object.prototype.hasOwnProperty.call(editorMetadata, "version") && Number(editorMetadata.version) !== 1) {
1831
1982
  this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
1832
1983
  }
1833
- this.canvas.loadFromJSON(state, async () => {
1984
+ const finishLoad = async () => {
1834
1985
  try {
1835
1986
  if (this._disposed || !this.canvas) {
1836
1987
  reject(new Error("Editor was disposed while loading state"));
@@ -1866,6 +2017,11 @@
1866
2017
  this.currentScale = 1;
1867
2018
  this.currentRotation = 0;
1868
2019
  }
2020
+ if (hasRestoredCanvasSize) {
2021
+ this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
2022
+ } else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
2023
+ this._updateCanvasSizeToImageBounds();
2024
+ }
1869
2025
  const masks = canvasObjects.filter((object) => object.maskId);
1870
2026
  masks.forEach((mask) => {
1871
2027
  this._restoreMaskControls(mask);
@@ -1893,6 +2049,9 @@
1893
2049
  this._reportError("loadFromState() failed", callbackError);
1894
2050
  reject(callbackError);
1895
2051
  }
2052
+ };
2053
+ this.canvas.loadFromJSON(state, () => {
2054
+ void finishLoad();
1896
2055
  });
1897
2056
  } catch (error) {
1898
2057
  this._reportError("loadFromState() failed", error);
@@ -2040,14 +2199,7 @@
2040
2199
  }
2041
2200
  _rebindMaskEvents(mask) {
2042
2201
  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
- }
2202
+ this._cleanupMaskEvents(mask);
2051
2203
  const metadata = {};
2052
2204
  if (!Number.isFinite(Number(mask.originalAlpha))) {
2053
2205
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
@@ -2074,6 +2226,22 @@
2074
2226
  mask.on("mouseout", mouseout);
2075
2227
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
2076
2228
  }
2229
+ _cleanupMaskEvents(mask) {
2230
+ if (!mask || !mask.__imageEditorMaskHandlers) return;
2231
+ try {
2232
+ if (typeof mask.off === "function") {
2233
+ mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
2234
+ mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
2235
+ }
2236
+ } catch (error) {
2237
+ this._reportWarning("Mask event cleanup failed", error);
2238
+ }
2239
+ try {
2240
+ delete mask.__imageEditorMaskHandlers;
2241
+ } catch (error) {
2242
+ this._reportWarning("Mask event metadata cleanup failed", error);
2243
+ }
2244
+ }
2077
2245
  /**
2078
2246
  * Creates a mask and adds it to the canvas.
2079
2247
  *
@@ -2284,6 +2452,7 @@
2284
2452
  this.canvas.discardActiveObject();
2285
2453
  selectedMasks.forEach((mask) => {
2286
2454
  this._removeLabelForMask(mask);
2455
+ this._cleanupMaskEvents(mask);
2287
2456
  this.canvas.remove(mask);
2288
2457
  });
2289
2458
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -2308,7 +2477,10 @@
2308
2477
  const saveHistory = options.saveHistory !== false;
2309
2478
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2310
2479
  masks.forEach((mask) => this._removeLabelForMask(mask));
2311
- masks.forEach((mask) => this.canvas.remove(mask));
2480
+ masks.forEach((mask) => {
2481
+ this._cleanupMaskEvents(mask);
2482
+ this.canvas.remove(mask);
2483
+ });
2312
2484
  this.canvas.discardActiveObject();
2313
2485
  this._lastMask = null;
2314
2486
  this._lastMaskInitialLeft = null;
@@ -2377,7 +2549,7 @@
2377
2549
  if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2378
2550
  this._syncMaskLabel(backup.mask);
2379
2551
  } catch (error) {
2380
- void error;
2552
+ this._reportWarning("restoreMaskLabelBackups: failed to restore mask label", error);
2381
2553
  }
2382
2554
  });
2383
2555
  }
@@ -2508,7 +2680,6 @@
2508
2680
  try {
2509
2681
  if (canvasObjectSet.has(label)) {
2510
2682
  this.canvas.remove(label);
2511
- canvasObjectSet.delete(label);
2512
2683
  }
2513
2684
  } catch (error) {
2514
2685
  void error;
@@ -2794,7 +2965,6 @@
2794
2965
  const maskStyleBackups = this._captureMaskExportBackups(masks);
2795
2966
  const labelBackups = this._captureMaskLabelBackups(masks);
2796
2967
  const activeObjectBackup = this._captureActiveObjectBackup();
2797
- let finalBase64;
2798
2968
  try {
2799
2969
  masks.forEach((mask) => this._removeLabelForMask(mask));
2800
2970
  this.canvas.discardActiveObject();
@@ -2807,7 +2977,7 @@
2807
2977
  this.originalImage.setCoords();
2808
2978
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2809
2979
  const exportRegion = this._getClampedCanvasRegion(imageBounds);
2810
- finalBase64 = await this._exportCanvasRegionToDataURL({
2980
+ return await this._exportCanvasRegionToDataURL({
2811
2981
  ...exportRegion,
2812
2982
  multiplier,
2813
2983
  quality,
@@ -2820,7 +2990,6 @@
2820
2990
  this._restoreActiveObjectBackup(activeObjectBackup);
2821
2991
  this.canvas.renderAll();
2822
2992
  }
2823
- return finalBase64;
2824
2993
  }
2825
2994
  /**
2826
2995
  * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
@@ -2947,22 +3116,21 @@
2947
3116
  this._cropPrevEvented = null;
2948
3117
  }
2949
3118
  _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) => {
3119
+ if (this._cropHandlers && this._cropHandlers.length) {
3120
+ this._cropHandlers.forEach((targetHandlers) => {
3121
+ (targetHandlers.handlers || []).forEach((handlerRecord) => {
3122
+ try {
2955
3123
  if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2956
3124
  targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2957
3125
  }
2958
- });
3126
+ } catch (error) {
3127
+ this._reportWarning("Crop handler cleanup failed", error);
3128
+ }
2959
3129
  });
2960
- }
2961
- } catch (error) {
2962
- void error;
3130
+ });
2963
3131
  }
2964
3132
  try {
2965
- if (this.canvas) this.canvas.remove(this._cropRect);
3133
+ if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect);
2966
3134
  } catch (error) {
2967
3135
  void error;
2968
3136
  }
@@ -3132,9 +3300,13 @@
3132
3300
  try {
3133
3301
  beforeJson = this._serializeCanvasState();
3134
3302
  } catch (error) {
3135
- this._reportWarning("applyCrop: could not serialize before state", error);
3303
+ this._reportError("applyCrop: failed to capture rollback state", error);
3136
3304
  beforeJson = null;
3137
3305
  }
3306
+ if (!beforeJson) {
3307
+ this.cancelCrop();
3308
+ return;
3309
+ }
3138
3310
  const preservedMasks = [];
3139
3311
  try {
3140
3312
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -3144,6 +3316,7 @@
3144
3316
  const maskBounds = mask.getBoundingRect(true, true);
3145
3317
  const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3146
3318
  this._removeLabelForMask(mask);
3319
+ this._cleanupMaskEvents(mask);
3147
3320
  this.canvas.remove(mask);
3148
3321
  if (shouldPreserveMasks && intersectsCrop) {
3149
3322
  this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
@@ -3214,7 +3387,7 @@
3214
3387
  * @private
3215
3388
  */
3216
3389
  _updateInputs() {
3217
- const scaleInputElement = this._getElement("scaleRate");
3390
+ const scaleInputElement = this._getElement("scalePercentageInput");
3218
3391
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
3219
3392
  }
3220
3393
  /**
@@ -3238,7 +3411,7 @@
3238
3411
  for (const key of Object.keys(this.elements || {})) {
3239
3412
  const element = this._getElement(key);
3240
3413
  if (!element) continue;
3241
- if (key === "applyCropBtn" || key === "cancelCropBtn") {
3414
+ if (key === "applyCropButton" || key === "cancelCropButton" || key === "applyCropBtn" || key === "cancelCropBtn") {
3242
3415
  this._setDisabled(key, false);
3243
3416
  } else {
3244
3417
  this._setDisabled(key, true);
@@ -3246,24 +3419,24 @@
3246
3419
  }
3247
3420
  return;
3248
3421
  }
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);
3422
+ this._setDisabled("zoomInButton", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3423
+ this._setDisabled("zoomOutButton", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3424
+ this._setDisabled("rotateLeftButton", !hasImage || isBusy);
3425
+ this._setDisabled("rotateRightButton", !hasImage || isBusy);
3426
+ this._setDisabled("createMaskButton", !hasImage || isBusy);
3427
+ this._setDisabled("removeSelectedMaskButton", !hasSelectedMask || isBusy);
3428
+ this._setDisabled("removeAllMasksButton", !hasMasks || isBusy);
3429
+ this._setDisabled("mergeMasksButton", !hasImage || !hasMasks || isBusy);
3430
+ this._setDisabled("downloadImageButton", !hasImage || isBusy);
3431
+ this._setDisabled("resetImageTransformButton", !hasImage || isDefaultTransform || isBusy);
3432
+ this._setDisabled("undoButton", !hasImage || isBusy || !canUndo);
3433
+ this._setDisabled("redoButton", !hasImage || isBusy || !canRedo);
3434
+ this._setDisabled("enterCropModeButton", !hasImage || isBusy);
3435
+ this._setDisabled("applyCropButton", true);
3436
+ this._setDisabled("cancelCropButton", true);
3437
+ this._setDisabled("scalePercentageInput", !hasImage || isBusy);
3438
+ this._setDisabled("rotateLeftDegreesInput", !hasImage || isBusy);
3439
+ this._setDisabled("rotateRightDegreesInput", !hasImage || isBusy);
3267
3440
  this._setDisabled("maskList", !hasImage || isBusy);
3268
3441
  this._setDisabled("imageInput", isBusy);
3269
3442
  this._setDisabled("uploadArea", isBusy);
@@ -3271,7 +3444,7 @@
3271
3444
  /**
3272
3445
  * Enables or disables a specific UI element (typically a button) by its key.
3273
3446
  *
3274
- * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
3447
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
3275
3448
  * @param {boolean} disabled - If true, disables the element; otherwise enables.
3276
3449
  * @private
3277
3450
  */
@@ -3395,14 +3568,7 @@
3395
3568
  } catch (error) {
3396
3569
  void error;
3397
3570
  }
3398
- if (this._cropRect) {
3399
- try {
3400
- this.canvas.remove(this._cropRect);
3401
- } catch (error) {
3402
- void error;
3403
- }
3404
- this._cropRect = null;
3405
- }
3571
+ if (this._cropRect) this._removeCropRect();
3406
3572
  if (this.containerElement && this._containerOriginalOverflow) {
3407
3573
  try {
3408
3574
  this._restoreContainerOverflowState();
@@ -3425,11 +3591,19 @@
3425
3591
  this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3426
3592
  this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3427
3593
  this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3594
+ this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
3428
3595
  } catch (error) {
3429
3596
  void error;
3430
3597
  }
3431
3598
  }
3432
3599
  if (this.canvas) {
3600
+ try {
3601
+ this.canvas.getObjects().forEach((object) => {
3602
+ if (object && object.maskId) this._cleanupMaskEvents(object);
3603
+ });
3604
+ } catch (error) {
3605
+ void error;
3606
+ }
3433
3607
  try {
3434
3608
  this.canvas.dispose();
3435
3609
  } catch (error) {
@@ -3526,7 +3700,7 @@
3526
3700
  task.reject(error);
3527
3701
  }
3528
3702
  } finally {
3529
- if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3703
+ if (this.currentTask === task) this.currentTask = null;
3530
3704
  }
3531
3705
  }
3532
3706
  } finally {