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