@bensitu/image-editor 1.2.2 → 1.3.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.2.2
8
+ * @version 1.3.0
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -76,6 +76,7 @@ var ImageEditor = class {
76
76
  downsampleMaxWidth: 4e3,
77
77
  downsampleMaxHeight: 3e3,
78
78
  downsampleQuality: 0.92,
79
+ imageLoadTimeoutMs: 3e4,
79
80
  exportMultiplier: 1,
80
81
  exportImageAreaByDefault: true,
81
82
  defaultMaskWidth: 50,
@@ -134,12 +135,16 @@ var ImageEditor = class {
134
135
  this._cropPrevEvented = null;
135
136
  this._prevSelectionSetting = void 0;
136
137
  this._containerOriginalOverflow = void 0;
138
+ this._scrollbarSizeCache = null;
137
139
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
138
- this.animQueue = new AnimationQueue();
140
+ this.animationQueue = new AnimationQueue();
139
141
  this.historyManager = new HistoryManager(this.maxHistorySize);
140
142
  }
141
143
  /**
142
- * @deprecated Use canvasElement instead.
144
+ * Backward-compatible alias for {@link ImageEditor#canvasElement}.
145
+ *
146
+ * @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
147
+ * @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
143
148
  */
144
149
  get canvasEl() {
145
150
  return this.canvasElement;
@@ -148,7 +153,10 @@ var ImageEditor = class {
148
153
  this.canvasElement = value;
149
154
  }
150
155
  /**
151
- * @deprecated Use containerElement instead.
156
+ * Backward-compatible alias for {@link ImageEditor#containerElement}.
157
+ *
158
+ * @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
159
+ * @returns {HTMLElement|null} The canvas viewport/container element.
152
160
  */
153
161
  get containerEl() {
154
162
  return this.containerElement;
@@ -157,7 +165,10 @@ var ImageEditor = class {
157
165
  this.containerElement = value;
158
166
  }
159
167
  /**
160
- * @deprecated Use placeholderElement instead.
168
+ * Backward-compatible alias for {@link ImageEditor#placeholderElement}.
169
+ *
170
+ * @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
171
+ * @returns {HTMLElement|null} The placeholder element shown before an image loads.
161
172
  */
162
173
  get placeholderEl() {
163
174
  return this.placeholderElement;
@@ -171,9 +182,10 @@ var ImageEditor = class {
171
182
  * Use this method to set up the editor UI before interacting with it.
172
183
  *
173
184
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
174
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput, rotationRightInput,
175
- * rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn, mergeBtn, downloadBtn, maskList,
176
- * zoomInBtn, zoomOutBtn, resetBtn, imageInput. Unknown keys are ignored.
185
+ * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
186
+ * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
187
+ * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
188
+ * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
177
189
  *
178
190
  * @returns {void}
179
191
  *
@@ -245,7 +257,9 @@ var ImageEditor = class {
245
257
  }
246
258
  }
247
259
  /**
248
- * Canvas setup helpers
260
+ * Initializes the Fabric canvas, viewport elements, and selection event handlers.
261
+ *
262
+ * @returns {void}
249
263
  * @private
250
264
  */
251
265
  _initCanvas() {
@@ -295,6 +309,13 @@ var ImageEditor = class {
295
309
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
296
310
  this.canvasElement.style.display = "block";
297
311
  }
312
+ /**
313
+ * Records a history entry after Fabric finishes modifying one or more masks.
314
+ *
315
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
316
+ * @returns {void}
317
+ * @private
318
+ */
298
319
  _handleObjectModified(target) {
299
320
  const masks = this._getModifiedMasks(target);
300
321
  if (!masks.length)
@@ -303,10 +324,17 @@ var ImageEditor = class {
303
324
  if (typeof mask.setCoords === "function")
304
325
  mask.setCoords();
305
326
  this._syncMaskLabel(mask);
306
- this._expandCanvasToFitObject(mask);
307
327
  });
328
+ this._expandCanvasToFitObjects(masks);
308
329
  this.saveState();
309
330
  }
331
+ /**
332
+ * Extracts editable mask objects from a Fabric modification target.
333
+ *
334
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
335
+ * @returns {Array<fabric.Object>} Modified mask objects.
336
+ * @private
337
+ */
310
338
  _getModifiedMasks(target) {
311
339
  if (!target)
312
340
  return [];
@@ -315,23 +343,33 @@ var ImageEditor = class {
315
343
  const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
316
344
  return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
317
345
  }
318
- _syncContainerOverflow() {
346
+ /**
347
+ * Updates container overflow behavior for fit and cover image modes.
348
+ *
349
+ * @param {Object} [options={}] - Overflow update options.
350
+ * @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
351
+ * @returns {void}
352
+ * @private
353
+ */
354
+ _syncContainerOverflow(options = {}) {
319
355
  if (!this.containerElement || !this.containerElement.style)
320
356
  return;
321
357
  if (this._containerOriginalOverflow === void 0) {
322
358
  this._containerOriginalOverflow = this.containerElement.style.overflow || "";
323
359
  }
360
+ const shouldPreserveScroll = options.preserveScroll === true;
324
361
  if (this.options.coverImageToCanvas) {
325
- const shouldResetScroll = !this.isImageLoadedToCanvas;
326
362
  this.containerElement.style.overflow = "scroll";
327
- if (shouldResetScroll) {
363
+ if (!shouldPreserveScroll) {
328
364
  this.containerElement.scrollLeft = 0;
329
365
  this.containerElement.scrollTop = 0;
330
366
  }
331
367
  } else if (this.options.fitImageToCanvas) {
332
368
  this.containerElement.style.overflow = "auto";
333
- this.containerElement.scrollLeft = 0;
334
- this.containerElement.scrollTop = 0;
369
+ if (!shouldPreserveScroll) {
370
+ this.containerElement.scrollLeft = 0;
371
+ this.containerElement.scrollTop = 0;
372
+ }
335
373
  } else {
336
374
  this.containerElement.style.overflow = this._containerOriginalOverflow;
337
375
  }
@@ -390,12 +428,12 @@ var ImageEditor = class {
390
428
  });
391
429
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
392
430
  }
393
- /**
394
- * Event binding element check
395
- *
396
- * @param {*} eventName
397
- * @param {*} handler
398
- * @param {*} key
431
+ /**
432
+ * Binds a DOM event listener when the configured element exists and records it for disposal.
433
+ *
434
+ * @param {string} key - Key in this.elements for the target DOM element.
435
+ * @param {string} eventName - DOM event name to listen for.
436
+ * @param {EventListener} handler - Event listener callback.
399
437
  * @private
400
438
  */
401
439
  _bindIfExists(key, eventName, handler) {
@@ -408,10 +446,10 @@ var ImageEditor = class {
408
446
  this._handlersByElementKey[key].push({ eventName, handler });
409
447
  }
410
448
  }
411
- /**
412
- * Image loading helpers
413
- *
414
- * @param {File} file
449
+ /**
450
+ * Reads an image File as a data URL and loads it into the Fabric canvas.
451
+ *
452
+ * @param {File} file - Image file selected by the user.
415
453
  * @private
416
454
  */
417
455
  _loadImageFile(file) {
@@ -425,19 +463,42 @@ var ImageEditor = class {
425
463
  reader.readAsDataURL(file);
426
464
  }
427
465
  /**
428
- * Load a base64 encoded image string into fabric.
429
- * @async
430
- * @param {String} imageBase64
466
+ * Warns when more than one mutually exclusive image layout mode is enabled.
467
+ *
468
+ * @returns {void}
469
+ * @private
470
+ */
471
+ _warnOnImageLayoutOptionConflict() {
472
+ const activeModes = [
473
+ ["fitImageToCanvas", this.options.fitImageToCanvas],
474
+ ["coverImageToCanvas", this.options.coverImageToCanvas],
475
+ ["expandCanvasToImage", this.options.expandCanvasToImage]
476
+ ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
477
+ if (activeModes.length <= 1)
478
+ return;
479
+ this._reportWarning(
480
+ `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
481
+ );
482
+ }
483
+ /**
484
+ * Loads a base64 data URL into the Fabric canvas as the base image.
485
+ *
486
+ * @async
487
+ * @param {string} imageBase64 - Image data URL beginning with `data:image/`.
488
+ * @param {LoadImageOptions} [options={}] - Optional load behavior.
489
+ * @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
490
+ * @public
431
491
  */
432
- async loadImage(imageBase64) {
492
+ async loadImage(imageBase64, options = {}) {
433
493
  if (!this._fabricLoaded)
434
494
  return;
435
495
  if (!this.canvas)
436
496
  return;
437
497
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
438
498
  return;
499
+ this._warnOnImageLayoutOptionConflict();
439
500
  this._setPlaceholderVisible(false);
440
- this._syncContainerOverflow();
501
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
441
502
  const imageElement = await this._createImageElement(imageBase64);
442
503
  let loadSource = imageBase64;
443
504
  if (this.options.downsampleOnLoad) {
@@ -468,8 +529,8 @@ var ImageEditor = class {
468
529
  const minWidth = viewport.width;
469
530
  const minHeight = viewport.height;
470
531
  if (this.options.fitImageToCanvas) {
471
- const canvasWidth = Math.max(1, Math.min(this.options.canvasWidth, minWidth) - 1);
472
- const canvasHeight = Math.max(1, Math.min(this.options.canvasHeight, minHeight) - 1);
532
+ const canvasWidth = Math.max(1, minWidth - 1);
533
+ const canvasHeight = Math.max(1, minHeight - 1);
473
534
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
474
535
  const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
475
536
  fabricImage.set({ left: 0, top: 0 });
@@ -539,22 +600,34 @@ var ImageEditor = class {
539
600
  * Creates an HTMLImageElement from a given data URL.
540
601
  *
541
602
  * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
603
+ * @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
542
604
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
543
605
  * @private
544
606
  */
545
- _createImageElement(dataUrl) {
607
+ _createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
546
608
  return new Promise((resolve, reject) => {
547
609
  const imageElement = new Image();
548
- imageElement.onload = () => {
549
- imageElement.onload = null;
550
- imageElement.onerror = null;
551
- resolve(imageElement);
552
- };
553
- imageElement.onerror = (error) => {
610
+ let isSettled = false;
611
+ const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
612
+ let timerId;
613
+ const settle = (callback) => {
614
+ if (isSettled)
615
+ return;
616
+ isSettled = true;
617
+ clearTimeout(timerId);
554
618
  imageElement.onload = null;
555
619
  imageElement.onerror = null;
556
- reject(error);
620
+ callback();
557
621
  };
622
+ timerId = setTimeout(() => {
623
+ settle(() => reject(new Error("Image load timed out")));
624
+ try {
625
+ imageElement.src = "";
626
+ } catch (error) {
627
+ }
628
+ }, safeTimeoutMs);
629
+ imageElement.onload = () => settle(() => resolve(imageElement));
630
+ imageElement.onerror = (error) => settle(() => reject(error));
558
631
  imageElement.src = dataUrl;
559
632
  });
560
633
  }
@@ -573,6 +646,8 @@ var ImageEditor = class {
573
646
  offscreenCanvas.width = targetWidth;
574
647
  offscreenCanvas.height = targetHeight;
575
648
  const context = offscreenCanvas.getContext("2d");
649
+ if (!context)
650
+ throw new Error("2D canvas context is unavailable");
576
651
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
577
652
  return offscreenCanvas.toDataURL("image/jpeg", quality);
578
653
  }
@@ -580,20 +655,20 @@ var ImageEditor = class {
580
655
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
581
656
  * Also updates the corresponding style attributes.
582
657
  *
583
- * @param {number} w - Canvas width (in pixels).
584
- * @param {number} h - Canvas height (in pixels).
658
+ * @param {number} width - Canvas width in pixels.
659
+ * @param {number} height - Canvas height in pixels.
585
660
  * @private
586
661
  */
587
- _setCanvasSizeInt(w, h) {
588
- const iw = Math.max(1, Math.round(Number(w) || 1));
589
- const ih = Math.max(1, Math.round(Number(h) || 1));
590
- this.canvas.setWidth(iw);
591
- this.canvas.setHeight(ih);
662
+ _setCanvasSizeInt(width, height) {
663
+ const integerWidth = Math.max(1, Math.round(Number(width) || 1));
664
+ const integerHeight = Math.max(1, Math.round(Number(height) || 1));
665
+ this.canvas.setWidth(integerWidth);
666
+ this.canvas.setHeight(integerHeight);
592
667
  if (typeof this.canvas.calcOffset === "function")
593
668
  this.canvas.calcOffset();
594
669
  if (this.canvasElement) {
595
- this.canvasElement.style.width = iw + "px";
596
- this.canvasElement.style.height = ih + "px";
670
+ this.canvasElement.style.width = integerWidth + "px";
671
+ this.canvasElement.style.height = integerHeight + "px";
597
672
  this.canvasElement.style.maxWidth = "none";
598
673
  }
599
674
  }
@@ -617,11 +692,8 @@ var ImageEditor = class {
617
692
  height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
618
693
  };
619
694
  }
620
- const previousOverflow = this.containerElement.style.overflow;
621
- this.containerElement.style.overflow = "hidden";
622
695
  const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
623
696
  const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
624
- this.containerElement.style.overflow = previousOverflow;
625
697
  return { width, height };
626
698
  }
627
699
  _hasFixedContainerScrollbars() {
@@ -642,6 +714,9 @@ var ImageEditor = class {
642
714
  return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
643
715
  }
644
716
  _getScrollbarSize() {
717
+ if (this._scrollbarSizeCache) {
718
+ return { ...this._scrollbarSizeCache };
719
+ }
645
720
  if (typeof document === "undefined" || !document.createElement || !document.body) {
646
721
  return { width: 0, height: 0 };
647
722
  }
@@ -656,7 +731,8 @@ var ImageEditor = class {
656
731
  const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
657
732
  const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
658
733
  document.body.removeChild(probe);
659
- return { width, height };
734
+ this._scrollbarSizeCache = { width, height };
735
+ return { ...this._scrollbarSizeCache };
660
736
  }
661
737
  _getScrollSafetyMargin() {
662
738
  return 2;
@@ -823,6 +899,25 @@ var ImageEditor = class {
823
899
  if (typeof mask.setCoords === "function")
824
900
  mask.setCoords();
825
901
  }
902
+ /**
903
+ * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
904
+ *
905
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
906
+ * @private
907
+ */
908
+ _serializeEditorMetadata() {
909
+ const baseImageScale = Number(this.baseImageScale);
910
+ const currentScale = Number(this.currentScale);
911
+ const currentRotation = Number(this.currentRotation);
912
+ const maskCounter = Number(this.maskCounter);
913
+ return {
914
+ version: 1,
915
+ baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
916
+ currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
917
+ currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
918
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
919
+ };
920
+ }
826
921
  _serializeCanvasState() {
827
922
  if (!this.canvas)
828
923
  return null;
@@ -831,15 +926,30 @@ var ImageEditor = class {
831
926
  if (Array.isArray(jsonObject.objects)) {
832
927
  jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
833
928
  }
929
+ jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
834
930
  return JSON.stringify(jsonObject);
835
931
  });
836
932
  }
933
+ /**
934
+ * Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
935
+ *
936
+ * @param {number} quality - Requested image quality.
937
+ * @returns {number} A finite quality value between 0 and 1.
938
+ * @private
939
+ */
837
940
  _normalizeQuality(quality) {
838
941
  const numericQuality = Number(quality);
839
942
  if (!Number.isFinite(numericQuality))
840
943
  return this.options.downsampleQuality ?? 0.92;
841
944
  return Math.max(0, Math.min(1, numericQuality));
842
945
  }
946
+ /**
947
+ * Normalizes public image format aliases to canvas export format names.
948
+ *
949
+ * @param {string} format - Requested image format or MIME type.
950
+ * @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
951
+ * @private
952
+ */
843
953
  _normalizeImageFormat(format) {
844
954
  const typeMapping = {
845
955
  "jpeg": "jpeg",
@@ -852,6 +962,15 @@ var ImageEditor = class {
852
962
  };
853
963
  return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
854
964
  }
965
+ /**
966
+ * Converts a bounding rectangle into a canvas-safe integer source region.
967
+ *
968
+ * @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
969
+ * @param {Object} [options={}] - Region rounding options.
970
+ * @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
971
+ * @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
972
+ * @private
973
+ */
855
974
  _getClampedCanvasRegion(bounds, options = {}) {
856
975
  const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
857
976
  const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
@@ -866,15 +985,49 @@ var ImageEditor = class {
866
985
  const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
867
986
  const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
868
987
  return {
869
- sx: sourceX,
870
- sy: sourceY,
871
- sw: Math.max(1, endX - sourceX),
872
- sh: Math.max(1, endY - sourceY)
988
+ sourceX,
989
+ sourceY,
990
+ sourceWidth: Math.max(1, endX - sourceX),
991
+ sourceHeight: Math.max(1, endY - sourceY)
873
992
  };
874
993
  }
994
+ /**
995
+ * Crops an image data URL to a source region using an offscreen canvas.
996
+ *
997
+ * @param {string} dataUrl - Source image data URL.
998
+ * @param {number} sourceX - Source region x coordinate.
999
+ * @param {number} sourceY - Source region y coordinate.
1000
+ * @param {number} sourceWidth - Source region width.
1001
+ * @param {number} sourceHeight - Source region height.
1002
+ * @param {number} multiplier - Export multiplier already applied to the source data URL.
1003
+ * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1004
+ * @param {number} [quality=0.92] - Output image quality for lossy formats.
1005
+ * @returns {Promise<string>} Resolves with the cropped image data URL.
1006
+ * @private
1007
+ */
875
1008
  async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
876
1009
  return new Promise((resolve, reject) => {
877
1010
  const imageElement = new Image();
1011
+ let isSettled = false;
1012
+ const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1013
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1014
+ let timerId;
1015
+ const settle = (callback) => {
1016
+ if (isSettled)
1017
+ return;
1018
+ isSettled = true;
1019
+ clearTimeout(timerId);
1020
+ imageElement.onload = null;
1021
+ imageElement.onerror = null;
1022
+ callback();
1023
+ };
1024
+ timerId = setTimeout(() => {
1025
+ settle(() => reject(new Error("Image crop load timed out")));
1026
+ try {
1027
+ imageElement.src = "";
1028
+ } catch (error) {
1029
+ }
1030
+ }, safeTimeoutMs);
878
1031
  imageElement.onload = () => {
879
1032
  try {
880
1033
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
@@ -886,24 +1039,40 @@ var ImageEditor = class {
886
1039
  offscreenCanvas.width = scaledSourceWidth;
887
1040
  offscreenCanvas.height = scaledSourceHeight;
888
1041
  const context = offscreenCanvas.getContext("2d");
1042
+ if (!context)
1043
+ throw new Error("2D canvas context is unavailable");
889
1044
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
890
- resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
1045
+ settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
891
1046
  } catch (error) {
892
- reject(error);
1047
+ settle(() => reject(error));
893
1048
  }
894
1049
  };
895
- imageElement.onerror = reject;
1050
+ imageElement.onerror = (error) => settle(() => reject(error));
896
1051
  imageElement.src = dataUrl;
897
1052
  });
898
1053
  }
899
- async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1054
+ /**
1055
+ * Exports the whole Fabric canvas, then crops the requested source region from that export.
1056
+ *
1057
+ * @param {Object} region - Canvas source region and export options.
1058
+ * @param {number} region.sourceX - Source region x coordinate.
1059
+ * @param {number} region.sourceY - Source region y coordinate.
1060
+ * @param {number} region.sourceWidth - Source region width.
1061
+ * @param {number} region.sourceHeight - Source region height.
1062
+ * @param {number} [region.multiplier=1] - Export multiplier.
1063
+ * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1064
+ * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1065
+ * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1066
+ * @private
1067
+ */
1068
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
900
1069
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
901
1070
  const fullDataUrl = this.canvas.toDataURL({
902
1071
  format,
903
1072
  quality,
904
1073
  multiplier: safeMultiplier
905
1074
  });
906
- return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
1075
+ return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
907
1076
  }
908
1077
  /**
909
1078
  * Gets the top-left corner coordinates of the given object.
@@ -969,23 +1138,60 @@ var ImageEditor = class {
969
1138
  const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
970
1139
  this._setCanvasSizeInt(size.width, size.height);
971
1140
  }
972
- _expandCanvasToFitObject(fabricObject, padding = 10) {
973
- if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage)
1141
+ /**
1142
+ * Whether post-load edits should resize the canvas to keep transformed content visible.
1143
+ *
1144
+ * @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
1145
+ * @private
1146
+ */
1147
+ _shouldResizeCanvasToContentBounds() {
1148
+ return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
1149
+ }
1150
+ /**
1151
+ * Expands the canvas once so all provided objects remain visible after an edit.
1152
+ *
1153
+ * @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
1154
+ * @param {number} [padding=10] - Extra canvas space after the farthest object edge.
1155
+ * @returns {void}
1156
+ * @private
1157
+ */
1158
+ _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1159
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds())
974
1160
  return;
975
1161
  try {
976
- fabricObject.setCoords();
977
- const boundingRect = fabricObject.getBoundingRect(true, true);
978
- const requiredWidth = Math.ceil(boundingRect.left + boundingRect.width + padding);
979
- const requiredHeight = Math.ceil(boundingRect.top + boundingRect.height + padding);
1162
+ let requiredWidth = this.canvas.getWidth();
1163
+ let requiredHeight = this.canvas.getHeight();
1164
+ fabricObjects.forEach((fabricObject) => {
1165
+ if (!fabricObject)
1166
+ return;
1167
+ if (typeof fabricObject.setCoords === "function")
1168
+ fabricObject.setCoords();
1169
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1170
+ requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1171
+ requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1172
+ });
980
1173
  const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
981
1174
  const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
982
1175
  const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
983
1176
  const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
984
- this._setCanvasSizeInt(newWidth, newHeight);
1177
+ if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1178
+ this._setCanvasSizeInt(newWidth, newHeight);
1179
+ }
985
1180
  } catch (error) {
986
- this._reportWarning("expandCanvasToFitObject: failed to expand canvas", error);
1181
+ this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
987
1182
  }
988
1183
  }
1184
+ /**
1185
+ * Expands the canvas so one object remains visible after an edit.
1186
+ *
1187
+ * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1188
+ * @param {number} [padding=10] - Extra canvas space after the object edge.
1189
+ * @returns {void}
1190
+ * @private
1191
+ */
1192
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1193
+ this._expandCanvasToFitObjects([fabricObject], padding);
1194
+ }
989
1195
  /**
990
1196
  * Scales the original image by a given factor, with animation.
991
1197
  * Returns a promise that resolves when the scale animation is complete.
@@ -994,7 +1200,7 @@ var ImageEditor = class {
994
1200
  * @public
995
1201
  */
996
1202
  scaleImage(factor, options = {}) {
997
- return this.animQueue.add(() => this._scaleImageImpl(factor, options));
1203
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
998
1204
  }
999
1205
  /**
1000
1206
  * Scales the original image by a given factor, with animation.
@@ -1033,7 +1239,7 @@ var ImageEditor = class {
1033
1239
  return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1034
1240
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1035
1241
  this.originalImage.setCoords();
1036
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1242
+ if (this._shouldResizeCanvasToContentBounds()) {
1037
1243
  this._updateCanvasSizeToImageBounds();
1038
1244
  }
1039
1245
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
@@ -1059,7 +1265,7 @@ var ImageEditor = class {
1059
1265
  * @public
1060
1266
  */
1061
1267
  rotateImage(degrees, options = {}) {
1062
- return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
1268
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1063
1269
  }
1064
1270
  /**
1065
1271
  * Rotates the original image by a given number of degrees, with animation.
@@ -1091,7 +1297,7 @@ var ImageEditor = class {
1091
1297
  return rotationAnimation.then(() => {
1092
1298
  this.originalImage.set("angle", degrees);
1093
1299
  this.originalImage.setCoords();
1094
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1300
+ if (this._shouldResizeCanvasToContentBounds()) {
1095
1301
  this._updateCanvasSizeToImageBounds();
1096
1302
  }
1097
1303
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
@@ -1113,38 +1319,47 @@ var ImageEditor = class {
1113
1319
  }
1114
1320
  /**
1115
1321
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
1116
- * @returns {Promise<void>} Promise that resolves when reset is complete.
1322
+ *
1323
+ * @returns {Promise<void>} Resolves when the reset history transition has been recorded.
1324
+ * @public
1117
1325
  */
1118
1326
  resetImageTransform() {
1119
1327
  if (!this.originalImage)
1120
1328
  return Promise.resolve();
1121
- return this.animQueue.add(async () => {
1122
- const before = this._serializeCanvasState();
1329
+ return this.animationQueue.add(async () => {
1330
+ const before = this._lastSnapshot || this._serializeCanvasState();
1123
1331
  await this._scaleImageImpl(1, { saveHistory: false });
1124
1332
  await this._rotateImageImpl(0, { saveHistory: false });
1125
1333
  const after = this._serializeCanvasState();
1126
1334
  this._pushStateTransition(before, after);
1127
- }).catch((err) => {
1128
- this._reportError("resetImageTransform() failed", err);
1335
+ }).catch((error) => {
1336
+ this._reportError("resetImageTransform() failed", error);
1129
1337
  });
1130
1338
  }
1131
1339
  /**
1132
- * @deprecated Use resetImageTransform() instead.
1340
+ * Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
1341
+ *
1342
+ * @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
1343
+ * @returns {Promise<void>} Resolves when the image transform reset is complete.
1133
1344
  */
1134
1345
  reset() {
1135
1346
  return this.resetImageTransform();
1136
1347
  }
1137
1348
  /**
1138
- * Restores a canvas state that was previously stored by saveState().
1139
- * @param {string} jsonString - the JSON string returned by fabric.toJSON().
1349
+ * Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
1350
+ *
1351
+ * @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
1352
+ * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1353
+ * @public
1140
1354
  */
1141
- loadFromState(jsonString) {
1142
- if (!jsonString || !this.canvas)
1355
+ loadFromState(serializedState) {
1356
+ if (!serializedState || !this.canvas)
1143
1357
  return Promise.resolve();
1144
1358
  return new Promise((resolve) => {
1145
1359
  try {
1146
- const json = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
1147
- this.canvas.loadFromJSON(json, () => {
1360
+ const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1361
+ const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1362
+ this.canvas.loadFromJSON(state, () => {
1148
1363
  try {
1149
1364
  this._hideAllMaskLabels();
1150
1365
  const canvasObjects = this.canvas.getObjects();
@@ -1152,11 +1367,22 @@ var ImageEditor = class {
1152
1367
  if (this.originalImage) {
1153
1368
  this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
1154
1369
  this.canvas.sendToBack(this.originalImage);
1155
- this.currentRotation = Number(this.originalImage.angle) || 0;
1156
- const baseScale = Number(this.baseImageScale) || 1;
1157
- const imageScale = Number(this.originalImage.scaleX) || baseScale;
1158
- this.currentScale = imageScale / baseScale;
1370
+ const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
1371
+ const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
1372
+ const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
1373
+ if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
1374
+ this.baseImageScale = restoredBaseScale;
1375
+ }
1376
+ if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
1377
+ this.currentScale = restoredCurrentScale;
1378
+ } else {
1379
+ const baseScale = Number(this.baseImageScale) || 1;
1380
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1381
+ this.currentScale = imageScale / baseScale;
1382
+ }
1383
+ this.currentRotation = Number.isFinite(restoredCurrentRotation) ? restoredCurrentRotation : Number(this.originalImage.angle) || 0;
1159
1384
  } else {
1385
+ this.baseImageScale = 1;
1160
1386
  this.currentScale = 1;
1161
1387
  this.currentRotation = 0;
1162
1388
  }
@@ -1166,7 +1392,9 @@ var ImageEditor = class {
1166
1392
  this._rebindMaskEvents(mask);
1167
1393
  mask.set(this._getMaskNormalStyle(mask));
1168
1394
  });
1169
- this.maskCounter = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
1395
+ const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
1396
+ const maxMaskId = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
1397
+ this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId ? Math.floor(restoredMaskCounter) : maxMaskId;
1170
1398
  this._lastMask = masks.length ? masks[masks.length - 1] : null;
1171
1399
  if (!this._lastMask) {
1172
1400
  this._lastMaskInitialLeft = null;
@@ -1193,13 +1421,18 @@ var ImageEditor = class {
1193
1421
  });
1194
1422
  }
1195
1423
  /**
1196
- * Saves the current state of the canvas to history, storing any mask/raster label information.
1424
+ * Saves the current editable canvas state as an undoable history transition.
1425
+ *
1426
+ * Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
1427
+ * mask objects and restored by `loadFromState()`.
1428
+ *
1429
+ * @returns {void}
1430
+ * @public
1197
1431
  */
1198
1432
  saveState() {
1199
1433
  if (!this.canvas)
1200
1434
  return;
1201
1435
  const activeObject = this.canvas.getActiveObject();
1202
- this._hideAllMaskLabels();
1203
1436
  try {
1204
1437
  const after = this._serializeCanvasState();
1205
1438
  const before = this._lastSnapshot || after;
@@ -1221,12 +1454,23 @@ var ImageEditor = class {
1221
1454
  } catch (error) {
1222
1455
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1223
1456
  } finally {
1224
- if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
1457
+ if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1225
1458
  this._handleSelectionChanged([activeObject]);
1226
1459
  }
1227
1460
  this._updateUI();
1228
1461
  }
1229
1462
  }
1463
+ /**
1464
+ * Pushes a precomputed before/after state transition into history.
1465
+ *
1466
+ * Use this for operations such as crop and merge that build their snapshots around asynchronous image
1467
+ * loading, where the "after" state is already applied before the history command is recorded.
1468
+ *
1469
+ * @param {string} before - Serialized state before the operation.
1470
+ * @param {string} after - Serialized state after the operation.
1471
+ * @returns {void}
1472
+ * @private
1473
+ */
1230
1474
  _pushStateTransition(before, after) {
1231
1475
  if (!before || !after)
1232
1476
  return;
@@ -1244,6 +1488,9 @@ var ImageEditor = class {
1244
1488
  }
1245
1489
  /**
1246
1490
  * Undo the last state change, if possible.
1491
+ *
1492
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
1493
+ * @public
1247
1494
  */
1248
1495
  undo() {
1249
1496
  return this.historyManager.undo().then(() => {
@@ -1254,6 +1501,9 @@ var ImageEditor = class {
1254
1501
  }
1255
1502
  /**
1256
1503
  * Redo the next state change, if possible.
1504
+ *
1505
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
1506
+ * @public
1257
1507
  */
1258
1508
  redo() {
1259
1509
  return this.historyManager.redo().then(() => {
@@ -1269,7 +1519,7 @@ var ImageEditor = class {
1269
1519
  try {
1270
1520
  mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
1271
1521
  mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
1272
- } catch (e) {
1522
+ } catch (error) {
1273
1523
  }
1274
1524
  }
1275
1525
  const metadata = {};
@@ -1307,23 +1557,32 @@ var ImageEditor = class {
1307
1557
  mask.on("mouseout", mouseout);
1308
1558
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
1309
1559
  }
1310
- /**
1560
+ /**
1311
1561
  * Creates a mask and adds it to the canvas.
1312
- * Mask placement and properties are determined by the provided config and instance options.
1313
- * Canvas and list UI are updated accordingly.
1314
- * @param {Object} [config={}] - Optional mask configuration overrides:
1315
- * @param {string} [config.shape='rect'] - 'rect', 'circle', 'ellipse', 'polygon', ...
1316
- * @param {Object|Array} [config.points] - Required for polygon: [{x, y}, ...] or [[x, y], ...]
1317
- * @param {number|function} [config.width/height/rx/ry/radius] - Can be number or function(canvas, options)
1318
- * @param {number|string|function} [config.left/top] - Absolute, %, or function
1319
- * @param {number|string} [config.angle] - Rotation angle (degree)
1320
- * @param {string} [config.color] - Fill color in CSS color format (default 'rgba(0,0,0,0.5)')
1321
- * @param {number} [config.alpha] - Opacity, from 0 to 1 (default 0.5)
1322
- * @param {boolean} [config.selectable=true]
1323
- * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
1324
- * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
1325
- * @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
1326
- * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
1562
+ *
1563
+ * Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
1564
+ * after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
1565
+ * so controls and hit testing stay in sync with Fabric 5.x behavior.
1566
+ *
1567
+ * @param {Object} [config={}] - Optional mask configuration overrides.
1568
+ * @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
1569
+ * @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
1570
+ * @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
1571
+ * @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
1572
+ * @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
1573
+ * @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
1574
+ * @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
1575
+ * @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
1576
+ * @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
1577
+ * @param {number} [config.angle=0] - Rotation angle in degrees.
1578
+ * @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
1579
+ * @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
1580
+ * @param {boolean} [config.selectable=true] - Whether the mask can be selected.
1581
+ * @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
1582
+ * @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
1583
+ * @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
1584
+ * @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
1585
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1327
1586
  * @public
1328
1587
  */
1329
1588
  createMask(config = {}) {
@@ -1371,6 +1630,8 @@ var ImageEditor = class {
1371
1630
  }
1372
1631
  maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1373
1632
  maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1633
+ maskConfig.left = left;
1634
+ maskConfig.top = top;
1374
1635
  let mask;
1375
1636
  if (typeof maskConfig.fabricGenerator === "function") {
1376
1637
  mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
@@ -1401,8 +1662,8 @@ var ImageEditor = class {
1401
1662
  break;
1402
1663
  case "polygon": {
1403
1664
  let polygonPoints = maskConfig.points || [];
1404
- if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === "object") {
1405
- polygonPoints = polygonPoints.map((point) => ({ x: Number(point.x), y: Number(point.y) }));
1665
+ if (Array.isArray(polygonPoints) && polygonPoints.length) {
1666
+ polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
1406
1667
  }
1407
1668
  mask = new fabric.Polygon(polygonPoints, {
1408
1669
  left,
@@ -1425,7 +1686,6 @@ var ImageEditor = class {
1425
1686
  opacity: maskConfig.alpha,
1426
1687
  angle: maskConfig.angle,
1427
1688
  rx: maskConfig.rx,
1428
- // Rounded Corners
1429
1689
  ry: maskConfig.ry,
1430
1690
  ...maskConfig.styles
1431
1691
  });
@@ -1443,6 +1703,7 @@ var ImageEditor = class {
1443
1703
  transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
1444
1704
  stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
1445
1705
  strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
1706
+ opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
1446
1707
  strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
1447
1708
  };
1448
1709
  if (hasStyle("strokeDashArray"))
@@ -1450,7 +1711,7 @@ var ImageEditor = class {
1450
1711
  mask.set(maskSettings);
1451
1712
  mask.setCoords();
1452
1713
  mask.set({
1453
- originalAlpha: maskConfig.alpha,
1714
+ originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
1454
1715
  originalStroke: mask.stroke || "#ccc",
1455
1716
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1456
1717
  });
@@ -1479,7 +1740,11 @@ var ImageEditor = class {
1479
1740
  return mask;
1480
1741
  }
1481
1742
  /**
1482
- * @deprecated Use createMask() instead.
1743
+ * Backward-compatible alias for {@link ImageEditor#createMask}.
1744
+ *
1745
+ * @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
1746
+ * @param {Object} [config={}] - Mask configuration passed to createMask().
1747
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1483
1748
  */
1484
1749
  addMask(config = {}) {
1485
1750
  return this.createMask(config);
@@ -1553,6 +1818,23 @@ var ImageEditor = class {
1553
1818
  }
1554
1819
  }
1555
1820
  }
1821
+ /**
1822
+ * Returns a stable zero-based creation index for label callbacks.
1823
+ *
1824
+ * Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
1825
+ * lifetime of a mask.
1826
+ *
1827
+ * @param {fabric.Object} mask - Mask object.
1828
+ * @returns {number} Stable zero-based creation index.
1829
+ * @private
1830
+ */
1831
+ _getMaskCreationIndex(mask) {
1832
+ const maskId = Number(mask && mask.maskId);
1833
+ if (Number.isFinite(maskId) && maskId > 0)
1834
+ return Math.floor(maskId) - 1;
1835
+ const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
1836
+ return Math.max(0, masks.indexOf(mask));
1837
+ }
1556
1838
  /**
1557
1839
  * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1558
1840
  * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
@@ -1584,9 +1866,7 @@ var ImageEditor = class {
1584
1866
  };
1585
1867
  if (this.options.label) {
1586
1868
  if (typeof this.options.label.getText === "function") {
1587
- const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
1588
- const maskIndex = Math.max(0, masks.indexOf(mask));
1589
- labelText = this.options.label.getText(mask, maskIndex);
1869
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
1590
1870
  }
1591
1871
  if (this.options.label.textOptions) {
1592
1872
  Object.assign(textOptions, this.options.label.textOptions);
@@ -1756,10 +2036,14 @@ var ImageEditor = class {
1756
2036
  });
1757
2037
  }
1758
2038
  /**
1759
- * Merges current masks into the image: exports a masked/cropped image, removes all masks, and re-imports the merged image.
1760
- * Will not run if no original image or no masks exist.
2039
+ * Flattens the current masks into the base image and reloads the flattened image.
2040
+ *
2041
+ * This removes editable mask objects after export and records the operation as one undoable history transition.
2042
+ * It does nothing when no base image or no masks exist.
2043
+ *
1761
2044
  * @async
1762
- * @returns {Promise<void>} Resolves when merge and load are complete.
2045
+ * @returns {Promise<void>} Resolves when the flattened image has been loaded.
2046
+ * @public
1763
2047
  */
1764
2048
  async mergeMasks() {
1765
2049
  if (!this.originalImage)
@@ -1773,49 +2057,58 @@ var ImageEditor = class {
1773
2057
  const beforeJson = this._serializeCanvasState();
1774
2058
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1775
2059
  this.removeAllMasks({ saveHistory: false });
1776
- await this.loadImage(merged);
2060
+ await this.loadImage(merged, { preserveScroll: true });
1777
2061
  const afterJson = this._serializeCanvasState();
1778
2062
  this._pushStateTransition(beforeJson, afterJson);
1779
- } catch (err) {
1780
- this._reportError("merge error", err);
2063
+ } catch (error) {
2064
+ this._reportError("merge error", error);
1781
2065
  }
1782
2066
  }
1783
2067
  /**
1784
- * @deprecated Use mergeMasks() instead.
2068
+ * Backward-compatible alias for {@link ImageEditor#mergeMasks}.
2069
+ *
2070
+ * @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
2071
+ * @returns {Promise<void>} Resolves when mask flattening is complete.
1785
2072
  */
1786
2073
  async merge() {
1787
2074
  return this.mergeMasks();
1788
2075
  }
1789
2076
  /**
1790
- * Triggers a JPEG image download of the current canvas (image plus masks if configured).
2077
+ * Triggers a JPEG image download of the current canvas.
2078
+ *
1791
2079
  * The image area and multiplier are controlled by options.
1792
2080
  * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
2081
+ * @returns {void}
2082
+ * @public
1793
2083
  */
1794
2084
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1795
2085
  if (!this.originalImage)
1796
2086
  return;
1797
2087
  const exportImageArea = this.options.exportImageAreaByDefault;
1798
- this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((base64) => {
2088
+ this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
1799
2089
  const link = document.createElement("a");
1800
2090
  link.download = fileName;
1801
- link.href = base64;
2091
+ link.href = imageBase64;
1802
2092
  document.body.appendChild(link);
1803
2093
  link.click();
1804
2094
  document.body.removeChild(link);
1805
- }).catch((err) => this._reportError("download error", err));
2095
+ }).catch((error) => this._reportError("download error", error));
1806
2096
  }
1807
2097
  /**
1808
- * Exports the image as a Base64-encoded image data URL.
1809
- * Can export either the original, or the current view including masks (clipped/cropped).
1810
- * Will restore masks' state after temporary modifications for export.
2098
+ * Exports the current image as a Base64-encoded data URL.
2099
+ *
2100
+ * When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
2101
+ * temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
2102
+ *
1811
2103
  * @async
1812
2104
  * @param {Object} [options={}] - Export options.
1813
2105
  * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1814
2106
  * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
1815
2107
  * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
1816
2108
  * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
1817
- * @returns {Promise<string>} Promise resolving to an image data URL.
2109
+ * @returns {Promise<string>} Resolves with an image data URL.
1818
2110
  * @throws {Error} If there is no image loaded.
2111
+ * @public
1819
2112
  */
1820
2113
  async exportImageBase64(options = {}) {
1821
2114
  if (!this.originalImage)
@@ -1835,12 +2128,9 @@ var ImageEditor = class {
1835
2128
  this.canvas.renderAll();
1836
2129
  this.originalImage.setCoords();
1837
2130
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1838
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2131
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1839
2132
  return await this._exportCanvasRegionToDataURL({
1840
- sx,
1841
- sy,
1842
- sw,
1843
- sh,
2133
+ ...exportRegion,
1844
2134
  multiplier,
1845
2135
  quality,
1846
2136
  format
@@ -1877,12 +2167,9 @@ var ImageEditor = class {
1877
2167
  this.canvas.renderAll();
1878
2168
  this.originalImage.setCoords();
1879
2169
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1880
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2170
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1881
2171
  finalBase64 = await this._exportCanvasRegionToDataURL({
1882
- sx,
1883
- sy,
1884
- sw,
1885
- sh,
2172
+ ...exportRegion,
1886
2173
  multiplier,
1887
2174
  quality,
1888
2175
  format
@@ -1907,14 +2194,20 @@ var ImageEditor = class {
1907
2194
  return finalBase64;
1908
2195
  }
1909
2196
  /**
1910
- * @deprecated Use exportImageBase64() instead.
2197
+ * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
2198
+ *
2199
+ * @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
2200
+ * @param {Object} [options={}] - Export options passed to exportImageBase64().
2201
+ * @returns {Promise<string>} Resolves with an image data URL.
1911
2202
  */
1912
2203
  async getImageBase64(options = {}) {
1913
2204
  return this.exportImageBase64(options);
1914
2205
  }
1915
2206
  /**
1916
- * Exports the current canvas (with or without masks) as a File object.
1917
- * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
2207
+ * Exports the current image as a File object.
2208
+ *
2209
+ * The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
2210
+ * Supported output formats are JPEG, PNG, and WebP.
1918
2211
  *
1919
2212
  * @async
1920
2213
  * @param {Object} [options={}] - Export options.
@@ -1939,23 +2232,23 @@ var ImageEditor = class {
1939
2232
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
1940
2233
  } = options;
1941
2234
  const safeFileType = this._normalizeImageFormat(fileType);
1942
- let base64;
2235
+ let imageBase64;
1943
2236
  if (mergeMask) {
1944
- base64 = await this.exportImageBase64({
2237
+ imageBase64 = await this.exportImageBase64({
1945
2238
  exportImageArea: true,
1946
2239
  multiplier,
1947
2240
  quality,
1948
2241
  fileType: safeFileType
1949
2242
  });
1950
2243
  } else {
1951
- base64 = await this.exportImageBase64({
2244
+ imageBase64 = await this.exportImageBase64({
1952
2245
  exportImageArea: false,
1953
2246
  multiplier,
1954
2247
  quality,
1955
2248
  fileType: safeFileType
1956
2249
  });
1957
2250
  }
1958
- let imageDataUrl = base64;
2251
+ let imageDataUrl = imageBase64;
1959
2252
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1960
2253
  imageDataUrl = await new Promise((resolve, reject) => {
1961
2254
  const imageElement = new window.Image();
@@ -1974,7 +2267,7 @@ var ImageEditor = class {
1974
2267
  }
1975
2268
  };
1976
2269
  imageElement.onerror = reject;
1977
- imageElement.src = base64;
2270
+ imageElement.src = imageBase64;
1978
2271
  });
1979
2272
  }
1980
2273
  const binaryString = atob(imageDataUrl.split(",")[1]);
@@ -2049,7 +2342,12 @@ var ImageEditor = class {
2049
2342
  this._cropHandlers = [];
2050
2343
  }
2051
2344
  /**
2052
- * Enter crop mode: create a resizable/movable selection rect on top of the image.
2345
+ * Enters crop mode by creating a resizable crop rectangle above the base image.
2346
+ *
2347
+ * Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
2348
+ * cropping when `crop.hideMasksDuringCrop` is enabled.
2349
+ *
2350
+ * @returns {void}
2053
2351
  * @public
2054
2352
  */
2055
2353
  enterCropMode() {
@@ -2066,8 +2364,14 @@ var ImageEditor = class {
2066
2364
  const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
2067
2365
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
2068
2366
  const top = Math.max(0, Math.floor(imageBounds.top + padding));
2069
- const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imageBounds.width - padding * 2));
2070
- const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imageBounds.height - padding * 2));
2367
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2368
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
2369
+ const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
2370
+ const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
2371
+ const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
2372
+ const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
2373
+ const width = minCropWidth;
2374
+ const height = minCropHeight;
2071
2375
  const cropRect = new fabric.Rect({
2072
2376
  left,
2073
2377
  top,
@@ -2084,7 +2388,8 @@ var ImageEditor = class {
2084
2388
  cornerSize: 8,
2085
2389
  objectCaching: false,
2086
2390
  originX: "left",
2087
- originY: "top"
2391
+ originY: "top",
2392
+ lockScalingFlip: true
2088
2393
  });
2089
2394
  this.canvas.add(cropRect);
2090
2395
  cropRect.isCropRect = true;
@@ -2110,6 +2415,11 @@ var ImageEditor = class {
2110
2415
  });
2111
2416
  const handleCropRectModified = () => {
2112
2417
  try {
2418
+ const cropWidth = Math.max(1, Number(cropRect.width) || 1);
2419
+ const cropHeight = Math.max(1, Number(cropRect.height) || 1);
2420
+ const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
2421
+ const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2422
+ cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2113
2423
  cropRect.setCoords();
2114
2424
  this.canvas.requestRenderAll();
2115
2425
  } catch (error) {
@@ -2130,7 +2440,9 @@ var ImageEditor = class {
2130
2440
  this.canvas.renderAll();
2131
2441
  }
2132
2442
  /**
2133
- * Cancel crop mode and remove the temporary selection rect.
2443
+ * Cancels crop mode and removes the temporary crop rectangle.
2444
+ *
2445
+ * @returns {void}
2134
2446
  * @public
2135
2447
  */
2136
2448
  cancelCrop() {
@@ -2146,8 +2458,14 @@ var ImageEditor = class {
2146
2458
  this.canvas.renderAll();
2147
2459
  }
2148
2460
  /**
2149
- * Apply the current crop rectangle.
2150
- * remove all masks and export canvas snapshot and crop via offscreen canvas
2461
+ * Applies the current crop rectangle to the base image.
2462
+ *
2463
+ * Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
2464
+ * region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
2465
+ * single undoable history transition.
2466
+ *
2467
+ * @async
2468
+ * @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
2151
2469
  * @public
2152
2470
  */
2153
2471
  async applyCrop() {
@@ -2155,7 +2473,7 @@ var ImageEditor = class {
2155
2473
  return;
2156
2474
  this._cropRect.setCoords();
2157
2475
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2158
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
2476
+ const cropRegion = this._getClampedCanvasRegion(rectBounds);
2159
2477
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2160
2478
  this._restoreCropObjectState();
2161
2479
  let beforeJson = null;
@@ -2173,13 +2491,13 @@ var ImageEditor = class {
2173
2491
  try {
2174
2492
  mask.setCoords();
2175
2493
  const maskBounds = mask.getBoundingRect(true, true);
2176
- const intersectsCrop = maskBounds.left < sx + sw && maskBounds.left + maskBounds.width > sx && maskBounds.top < sy + sh && maskBounds.top + maskBounds.height > sy;
2494
+ 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;
2177
2495
  this._removeLabelForMask(mask);
2178
2496
  this.canvas.remove(mask);
2179
2497
  if (shouldPreserveMasks && intersectsCrop) {
2180
2498
  mask.set({
2181
- left: (mask.left || 0) - sx,
2182
- top: (mask.top || 0) - sy,
2499
+ left: (mask.left || 0) - cropRegion.sourceX,
2500
+ top: (mask.top || 0) - cropRegion.sourceY,
2183
2501
  visible: true
2184
2502
  });
2185
2503
  mask.setCoords();
@@ -2203,10 +2521,7 @@ var ImageEditor = class {
2203
2521
  let croppedBase64;
2204
2522
  try {
2205
2523
  croppedBase64 = await this._exportCanvasRegionToDataURL({
2206
- sx,
2207
- sy,
2208
- sw,
2209
- sh,
2524
+ ...cropRegion,
2210
2525
  multiplier: 1,
2211
2526
  quality: this._normalizeQuality(this.options.downsampleQuality),
2212
2527
  format: "jpeg"
@@ -2228,21 +2543,21 @@ var ImageEditor = class {
2228
2543
  this._updateMaskList();
2229
2544
  this.canvas.renderAll();
2230
2545
  }
2231
- } catch (e) {
2232
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", e);
2546
+ } catch (error) {
2547
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
2233
2548
  return;
2234
2549
  }
2235
2550
  let afterJson = null;
2236
2551
  try {
2237
2552
  afterJson = this._serializeCanvasState();
2238
- } catch (e) {
2239
- this._reportWarning("applyCrop: failed to serialize after state", e);
2553
+ } catch (error) {
2554
+ this._reportWarning("applyCrop: failed to serialize after state", error);
2240
2555
  afterJson = null;
2241
2556
  }
2242
2557
  try {
2243
2558
  this._pushStateTransition(beforeJson, afterJson);
2244
- } catch (e) {
2245
- this._reportWarning("applyCrop: failed to push history command", e);
2559
+ } catch (error) {
2560
+ this._reportWarning("applyCrop: failed to push history command", error);
2246
2561
  }
2247
2562
  this._updateUI();
2248
2563
  this.canvas.renderAll();
@@ -2335,7 +2650,7 @@ var ImageEditor = class {
2335
2650
  return element.getAttribute("aria-disabled") === "true";
2336
2651
  }
2337
2652
  /**
2338
- * Automatically display and hide placeholders and containers based on the current image content
2653
+ * Updates placeholder and canvas container visibility based on whether an image is loaded.
2339
2654
  * @private
2340
2655
  */
2341
2656
  _updatePlaceholderStatus() {
@@ -2344,12 +2659,13 @@ var ImageEditor = class {
2344
2659
  this._setPlaceholderVisible(!this.originalImage);
2345
2660
  }
2346
2661
  /**
2347
- * Controls the display/hiding of the Placeholder and Canvas container.
2348
- * @param {boolean} show - true displays the placeholder, false displays the canvas container
2662
+ * Shows or hides the placeholder and canvas container.
2663
+ *
2664
+ * @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
2349
2665
  * @private
2350
2666
  */
2351
2667
  _setPlaceholderVisible(show) {
2352
- if (!this.placeholderElement)
2668
+ if (!this.placeholderElement || !this.containerElement)
2353
2669
  return;
2354
2670
  if (show) {
2355
2671
  this.placeholderElement.classList.remove("d-none");
@@ -2385,20 +2701,20 @@ var ImageEditor = class {
2385
2701
  if (this._cropRect) {
2386
2702
  try {
2387
2703
  this.canvas.remove(this._cropRect);
2388
- } catch (e) {
2704
+ } catch (error) {
2389
2705
  }
2390
2706
  this._cropRect = null;
2391
2707
  }
2392
2708
  if (this.containerElement && this._containerOriginalOverflow !== void 0) {
2393
2709
  try {
2394
2710
  this.containerElement.style.overflow = this._containerOriginalOverflow;
2395
- } catch (e) {
2711
+ } catch (error) {
2396
2712
  }
2397
2713
  }
2398
2714
  if (this.canvas) {
2399
2715
  try {
2400
2716
  this.canvas.dispose();
2401
- } catch (e) {
2717
+ } catch (error) {
2402
2718
  }
2403
2719
  this.canvas = null;
2404
2720
  this.canvasElement = null;
@@ -2409,54 +2725,52 @@ var ImageEditor = class {
2409
2725
  };
2410
2726
  var AnimationQueue = class {
2411
2727
  /**
2412
- * Creates a new AnimationQueue.
2413
- *
2414
- * @constructor
2728
+ * Creates an empty animation queue.
2415
2729
  */
2416
2730
  constructor() {
2417
- this.queue = [];
2418
- this.running = false;
2731
+ this.animationTasks = [];
2732
+ this.isRunning = false;
2419
2733
  }
2420
2734
  /**
2421
2735
  * Adds an animation function to the queue.
2422
2736
  *
2423
- * @param {Function} animationFn A function that returns a Promise or any await-able.
2424
- * @returns {Promise<*>} A Promise that resolves/rejects with the animation result.
2737
+ * @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
2738
+ * @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
2425
2739
  */
2426
2740
  async add(animationFn) {
2427
2741
  return new Promise((resolve, reject) => {
2428
- this.queue.push({ fn: animationFn, resolve, reject });
2429
- if (!this.running) {
2430
- this.processQueue();
2742
+ this.animationTasks.push({ animationFn, resolve, reject });
2743
+ if (!this.isRunning) {
2744
+ this._drainQueue();
2431
2745
  }
2432
2746
  });
2433
2747
  }
2434
2748
  /**
2435
- * Internal helper that processes the animation queue sequentially until it is empty.
2749
+ * Runs queued animation tasks sequentially until the queue is empty.
2436
2750
  *
2437
2751
  * @private
2438
2752
  * @returns {Promise<void>}
2439
2753
  */
2440
- async processQueue() {
2441
- if (this.queue.length === 0) {
2442
- this.running = false;
2754
+ async _drainQueue() {
2755
+ if (this.animationTasks.length === 0) {
2756
+ this.isRunning = false;
2443
2757
  return;
2444
2758
  }
2445
- this.running = true;
2446
- const { fn, resolve, reject } = this.queue.shift();
2759
+ this.isRunning = true;
2760
+ const { animationFn, resolve, reject } = this.animationTasks.shift();
2447
2761
  try {
2448
- const result = await fn();
2762
+ const result = await animationFn();
2449
2763
  resolve(result);
2450
2764
  } catch (error) {
2451
2765
  reject(error);
2452
2766
  }
2453
- this.processQueue();
2767
+ await this._drainQueue();
2454
2768
  }
2455
2769
  };
2456
2770
  var Command = class {
2457
2771
  /**
2458
- * @param {Function} execute The function that performs the action.
2459
- * @param {Function} undo The function that reverts the action.
2772
+ * @param {HistoryTaskCallback} execute - Function that performs the action.
2773
+ * @param {HistoryTaskCallback} undo - Function that reverts the action.
2460
2774
  */
2461
2775
  constructor(execute, undo) {
2462
2776
  this.execute = execute;
@@ -2465,7 +2779,7 @@ var Command = class {
2465
2779
  };
2466
2780
  var HistoryManager = class {
2467
2781
  /**
2468
- * @param {number} [maxSize=50] Maximum number of commands to keep in history.
2782
+ * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
2469
2783
  */
2470
2784
  constructor(maxSize = 50) {
2471
2785
  this.history = [];
@@ -2473,11 +2787,24 @@ var HistoryManager = class {
2473
2787
  this.maxSize = maxSize;
2474
2788
  this.pending = Promise.resolve();
2475
2789
  }
2790
+ /**
2791
+ * Queues a history task after the previously queued undo/redo task completes.
2792
+ *
2793
+ * @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
2794
+ * @returns {Promise<void>} Resolves or rejects with the queued task result.
2795
+ * @private
2796
+ */
2476
2797
  enqueue(task) {
2477
- const run = this.pending.then(task, task);
2478
- this.pending = run.catch(() => {
2479
- });
2480
- return run;
2798
+ const nextTask = this.pending.then(task, task);
2799
+ let pendingAfterTask;
2800
+ const resetPending = () => {
2801
+ if (this.pending === pendingAfterTask) {
2802
+ this.pending = Promise.resolve();
2803
+ }
2804
+ };
2805
+ pendingAfterTask = nextTask.then(resetPending, resetPending);
2806
+ this.pending = pendingAfterTask;
2807
+ return nextTask;
2481
2808
  }
2482
2809
  /**
2483
2810
  * Executes a new command and pushes it onto the history stack.
@@ -2527,7 +2854,7 @@ var HistoryManager = class {
2527
2854
  /**
2528
2855
  * Undoes the last executed command if possible.
2529
2856
  *
2530
- * @returns {void}
2857
+ * @returns {Promise<void>} Resolves after the undo task completes.
2531
2858
  */
2532
2859
  undo() {
2533
2860
  return this.enqueue(async () => {
@@ -2541,7 +2868,7 @@ var HistoryManager = class {
2541
2868
  /**
2542
2869
  * Redoes the next command in history if possible.
2543
2870
  *
2544
- * @returns {void}
2871
+ * @returns {Promise<void>} Resolves after the redo task completes.
2545
2872
  */
2546
2873
  redo() {
2547
2874
  return this.enqueue(async () => {