@bensitu/image-editor 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,19 +5,16 @@ 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.1
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
12
12
  */
13
13
  var fabric = null;
14
14
  function getGlobalScope() {
15
- if (typeof globalThis !== "undefined")
16
- return globalThis;
17
- if (typeof self !== "undefined")
18
- return self;
19
- if (typeof window !== "undefined")
20
- return window;
15
+ if (typeof globalThis !== "undefined") return globalThis;
16
+ if (typeof self !== "undefined") return self;
17
+ if (typeof window !== "undefined") return window;
21
18
  return null;
22
19
  }
23
20
  function getGlobalFabric() {
@@ -29,8 +26,7 @@ function setFabric(fabricInstance2) {
29
26
  return fabric;
30
27
  }
31
28
  function ensureFabric() {
32
- if (!fabric)
33
- setFabric();
29
+ if (!fabric) setFabric();
34
30
  return fabric;
35
31
  }
36
32
  var ImageEditor = class {
@@ -76,6 +72,7 @@ var ImageEditor = class {
76
72
  downsampleMaxWidth: 4e3,
77
73
  downsampleMaxHeight: 3e3,
78
74
  downsampleQuality: 0.92,
75
+ imageLoadTimeoutMs: 3e4,
79
76
  exportMultiplier: 1,
80
77
  exportImageAreaByDefault: true,
81
78
  defaultMaskWidth: 50,
@@ -134,12 +131,16 @@ var ImageEditor = class {
134
131
  this._cropPrevEvented = null;
135
132
  this._prevSelectionSetting = void 0;
136
133
  this._containerOriginalOverflow = void 0;
134
+ this._scrollbarSizeCache = null;
137
135
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
138
- this.animQueue = new AnimationQueue();
136
+ this.animationQueue = new AnimationQueue();
139
137
  this.historyManager = new HistoryManager(this.maxHistorySize);
140
138
  }
141
139
  /**
142
- * @deprecated Use canvasElement instead.
140
+ * Backward-compatible alias for {@link ImageEditor#canvasElement}.
141
+ *
142
+ * @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
143
+ * @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
143
144
  */
144
145
  get canvasEl() {
145
146
  return this.canvasElement;
@@ -148,7 +149,10 @@ var ImageEditor = class {
148
149
  this.canvasElement = value;
149
150
  }
150
151
  /**
151
- * @deprecated Use containerElement instead.
152
+ * Backward-compatible alias for {@link ImageEditor#containerElement}.
153
+ *
154
+ * @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
155
+ * @returns {HTMLElement|null} The canvas viewport/container element.
152
156
  */
153
157
  get containerEl() {
154
158
  return this.containerElement;
@@ -157,7 +161,10 @@ var ImageEditor = class {
157
161
  this.containerElement = value;
158
162
  }
159
163
  /**
160
- * @deprecated Use placeholderElement instead.
164
+ * Backward-compatible alias for {@link ImageEditor#placeholderElement}.
165
+ *
166
+ * @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
167
+ * @returns {HTMLElement|null} The placeholder element shown before an image loads.
161
168
  */
162
169
  get placeholderEl() {
163
170
  return this.placeholderElement;
@@ -171,9 +178,10 @@ var ImageEditor = class {
171
178
  * Use this method to set up the editor UI before interacting with it.
172
179
  *
173
180
  * @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.
181
+ * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
182
+ * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
183
+ * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
184
+ * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
177
185
  *
178
186
  * @returns {void}
179
187
  *
@@ -186,8 +194,7 @@ var ImageEditor = class {
186
194
  * });
187
195
  */
188
196
  init(idMap = {}) {
189
- if (!this._fabricLoaded)
190
- return;
197
+ if (!this._fabricLoaded) return;
191
198
  const defaults = {
192
199
  canvas: "fabricCanvas",
193
200
  canvasContainer: null,
@@ -228,8 +235,7 @@ var ImageEditor = class {
228
235
  }
229
236
  _reportError(message, error = null) {
230
237
  const handler = this.options && this.options.onError;
231
- if (typeof handler !== "function")
232
- return;
238
+ if (typeof handler !== "function") return;
233
239
  try {
234
240
  handler(error, message);
235
241
  } catch {
@@ -237,21 +243,21 @@ var ImageEditor = class {
237
243
  }
238
244
  _reportWarning(message, error = null) {
239
245
  const handler = this.options && this.options.onWarning;
240
- if (typeof handler !== "function")
241
- return;
246
+ if (typeof handler !== "function") return;
242
247
  try {
243
248
  handler(error, message);
244
249
  } catch {
245
250
  }
246
251
  }
247
252
  /**
248
- * Canvas setup helpers
253
+ * Initializes the Fabric canvas, viewport elements, and selection event handlers.
254
+ *
255
+ * @returns {void}
249
256
  * @private
250
257
  */
251
258
  _initCanvas() {
252
259
  const canvasElement = document.getElementById(this.elements.canvas);
253
- if (!canvasElement)
254
- throw new Error("Canvas is not found: " + this.elements.canvas);
260
+ if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
255
261
  this.canvasElement = canvasElement;
256
262
  if (this.elements.canvasContainer) {
257
263
  const containerElement = document.getElementById(this.elements.canvasContainer);
@@ -281,57 +287,73 @@ var ImageEditor = class {
281
287
  this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
282
288
  this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
283
289
  this.canvas.on("object:moving", (event) => {
284
- if (event.target && event.target.maskId)
285
- this._syncMaskLabel(event.target);
290
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
286
291
  });
287
292
  this.canvas.on("object:scaling", (event) => {
288
- if (event.target && event.target.maskId)
289
- this._syncMaskLabel(event.target);
293
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
290
294
  });
291
295
  this.canvas.on("object:rotating", (event) => {
292
- if (event.target && event.target.maskId)
293
- this._syncMaskLabel(event.target);
296
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
294
297
  });
295
298
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
296
299
  this.canvasElement.style.display = "block";
297
300
  }
301
+ /**
302
+ * Records a history entry after Fabric finishes modifying one or more masks.
303
+ *
304
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
305
+ * @returns {void}
306
+ * @private
307
+ */
298
308
  _handleObjectModified(target) {
299
309
  const masks = this._getModifiedMasks(target);
300
- if (!masks.length)
301
- return;
310
+ if (!masks.length) return;
302
311
  masks.forEach((mask) => {
303
- if (typeof mask.setCoords === "function")
304
- mask.setCoords();
312
+ if (typeof mask.setCoords === "function") mask.setCoords();
305
313
  this._syncMaskLabel(mask);
306
- this._expandCanvasToFitObject(mask);
307
314
  });
315
+ this._expandCanvasToFitObjects(masks);
308
316
  this.saveState();
309
317
  }
318
+ /**
319
+ * Extracts editable mask objects from a Fabric modification target.
320
+ *
321
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
322
+ * @returns {Array<fabric.Object>} Modified mask objects.
323
+ * @private
324
+ */
310
325
  _getModifiedMasks(target) {
311
- if (!target)
312
- return [];
313
- if (target.maskId)
314
- return [target];
326
+ if (!target) return [];
327
+ if (target.maskId) return [target];
315
328
  const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
316
329
  return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
317
330
  }
318
- _syncContainerOverflow() {
319
- if (!this.containerElement || !this.containerElement.style)
320
- return;
331
+ /**
332
+ * Updates container overflow behavior for fit and cover image modes.
333
+ *
334
+ * @param {Object} [options={}] - Overflow update options.
335
+ * @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
336
+ * @returns {void}
337
+ * @private
338
+ */
339
+ _syncContainerOverflow(options = {}) {
340
+ if (!this.containerElement || !this.containerElement.style) return;
321
341
  if (this._containerOriginalOverflow === void 0) {
322
342
  this._containerOriginalOverflow = this.containerElement.style.overflow || "";
323
343
  }
344
+ const shouldPreserveScroll = options.preserveScroll === true;
324
345
  if (this.options.coverImageToCanvas) {
325
- const shouldResetScroll = !this.isImageLoadedToCanvas;
326
346
  this.containerElement.style.overflow = "scroll";
327
- if (shouldResetScroll) {
347
+ if (!shouldPreserveScroll) {
328
348
  this.containerElement.scrollLeft = 0;
329
349
  this.containerElement.scrollTop = 0;
330
350
  }
331
351
  } else if (this.options.fitImageToCanvas) {
332
352
  this.containerElement.style.overflow = "auto";
333
- this.containerElement.scrollLeft = 0;
334
- this.containerElement.scrollTop = 0;
353
+ if (!shouldPreserveScroll) {
354
+ this.containerElement.scrollLeft = 0;
355
+ this.containerElement.scrollTop = 0;
356
+ }
335
357
  } else {
336
358
  this.containerElement.style.overflow = this._containerOriginalOverflow;
337
359
  }
@@ -343,14 +365,12 @@ var ImageEditor = class {
343
365
  _bindEvents() {
344
366
  this._bindIfExists("uploadArea", "click", () => {
345
367
  const uploadAreaElement = document.getElementById(this.elements.uploadArea);
346
- if (this._isElementDisabled(uploadAreaElement))
347
- return;
368
+ if (this._isElementDisabled(uploadAreaElement)) return;
348
369
  document.getElementById(this.elements.imageInput)?.click();
349
370
  });
350
371
  this._bindIfExists("imageInput", "change", (event) => {
351
372
  const file = event.target.files && event.target.files[0];
352
- if (file)
353
- this._loadImageFile(file);
373
+ if (file) this._loadImageFile(file);
354
374
  });
355
375
  this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
356
376
  this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
@@ -369,8 +389,7 @@ var ImageEditor = class {
369
389
  let step = this.options.rotationStep;
370
390
  if (rotationInputElement) {
371
391
  const parsedStep = parseFloat(rotationInputElement.value);
372
- if (!isNaN(parsedStep))
373
- step = parsedStep;
392
+ if (!isNaN(parsedStep)) step = parsedStep;
374
393
  }
375
394
  this.rotateImage(this.currentRotation - step);
376
395
  });
@@ -379,8 +398,7 @@ var ImageEditor = class {
379
398
  let step = this.options.rotationStep;
380
399
  if (rotationInputElement) {
381
400
  const parsedStep = parseFloat(rotationInputElement.value);
382
- if (!isNaN(parsedStep))
383
- step = parsedStep;
401
+ if (!isNaN(parsedStep)) step = parsedStep;
384
402
  }
385
403
  this.rotateImage(this.currentRotation + step);
386
404
  });
@@ -390,12 +408,12 @@ var ImageEditor = class {
390
408
  });
391
409
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
392
410
  }
393
- /**
394
- * Event binding element check
395
- *
396
- * @param {*} eventName
397
- * @param {*} handler
398
- * @param {*} key
411
+ /**
412
+ * Binds a DOM event listener when the configured element exists and records it for disposal.
413
+ *
414
+ * @param {string} key - Key in this.elements for the target DOM element.
415
+ * @param {string} eventName - DOM event name to listen for.
416
+ * @param {EventListener} handler - Event listener callback.
399
417
  * @private
400
418
  */
401
419
  _bindIfExists(key, eventName, handler) {
@@ -403,20 +421,18 @@ var ImageEditor = class {
403
421
  if (element) {
404
422
  element.addEventListener(eventName, handler);
405
423
  this._handlersByElementKey = this._handlersByElementKey || {};
406
- if (!this._handlersByElementKey[key])
407
- this._handlersByElementKey[key] = [];
424
+ if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
408
425
  this._handlersByElementKey[key].push({ eventName, handler });
409
426
  }
410
427
  }
411
- /**
412
- * Image loading helpers
413
- *
414
- * @param {File} file
428
+ /**
429
+ * Reads an image File as a data URL and loads it into the Fabric canvas.
430
+ *
431
+ * @param {File} file - Image file selected by the user.
415
432
  * @private
416
433
  */
417
434
  _loadImageFile(file) {
418
- if (!file || !file.type.startsWith("image/"))
419
- return;
435
+ if (!file || !file.type.startsWith("image/")) return;
420
436
  const reader = new FileReader();
421
437
  reader.onload = (event) => this.loadImage(event.target.result);
422
438
  reader.onerror = (event) => {
@@ -425,19 +441,38 @@ var ImageEditor = class {
425
441
  reader.readAsDataURL(file);
426
442
  }
427
443
  /**
428
- * Load a base64 encoded image string into fabric.
429
- * @async
430
- * @param {String} imageBase64
444
+ * Warns when more than one mutually exclusive image layout mode is enabled.
445
+ *
446
+ * @returns {void}
447
+ * @private
431
448
  */
432
- async loadImage(imageBase64) {
433
- if (!this._fabricLoaded)
434
- return;
435
- if (!this.canvas)
436
- return;
437
- if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
438
- return;
449
+ _warnOnImageLayoutOptionConflict() {
450
+ const activeModes = [
451
+ ["fitImageToCanvas", this.options.fitImageToCanvas],
452
+ ["coverImageToCanvas", this.options.coverImageToCanvas],
453
+ ["expandCanvasToImage", this.options.expandCanvasToImage]
454
+ ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
455
+ if (activeModes.length <= 1) return;
456
+ this._reportWarning(
457
+ `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
458
+ );
459
+ }
460
+ /**
461
+ * Loads a base64 data URL into the Fabric canvas as the base image.
462
+ *
463
+ * @async
464
+ * @param {string} imageBase64 - Image data URL beginning with `data:image/`.
465
+ * @param {LoadImageOptions} [options={}] - Optional load behavior.
466
+ * @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
467
+ * @public
468
+ */
469
+ async loadImage(imageBase64, options = {}) {
470
+ if (!this._fabricLoaded) return;
471
+ if (!this.canvas) return;
472
+ if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
473
+ this._warnOnImageLayoutOptionConflict();
439
474
  this._setPlaceholderVisible(false);
440
- this._syncContainerOverflow();
475
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
441
476
  const imageElement = await this._createImageElement(imageBase64);
442
477
  let loadSource = imageBase64;
443
478
  if (this.options.downsampleOnLoad) {
@@ -455,8 +490,7 @@ var ImageEditor = class {
455
490
  return new Promise((resolve, reject) => {
456
491
  fabric.Image.fromURL(loadSource, (fabricImage) => {
457
492
  try {
458
- if (!fabricImage)
459
- throw new Error("Image could not be loaded");
493
+ if (!fabricImage) throw new Error("Image could not be loaded");
460
494
  this.canvas.discardActiveObject();
461
495
  this._hideAllMaskLabels();
462
496
  this.canvas.clear();
@@ -468,8 +502,8 @@ var ImageEditor = class {
468
502
  const minWidth = viewport.width;
469
503
  const minHeight = viewport.height;
470
504
  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);
505
+ const canvasWidth = Math.max(1, minWidth - 1);
506
+ const canvasHeight = Math.max(1, minHeight - 1);
473
507
  this._setCanvasSizeInt(canvasWidth, canvasHeight);
474
508
  const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
475
509
  fabricImage.set({ left: 0, top: 0 });
@@ -539,22 +573,34 @@ var ImageEditor = class {
539
573
  * Creates an HTMLImageElement from a given data URL.
540
574
  *
541
575
  * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
576
+ * @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
542
577
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
543
578
  * @private
544
579
  */
545
- _createImageElement(dataUrl) {
580
+ _createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
546
581
  return new Promise((resolve, reject) => {
547
582
  const imageElement = new Image();
548
- imageElement.onload = () => {
549
- imageElement.onload = null;
550
- imageElement.onerror = null;
551
- resolve(imageElement);
552
- };
553
- imageElement.onerror = (error) => {
583
+ let isSettled = false;
584
+ const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
585
+ let timerId;
586
+ const settle = (callback) => {
587
+ if (isSettled) return;
588
+ isSettled = true;
589
+ clearTimeout(timerId);
554
590
  imageElement.onload = null;
555
591
  imageElement.onerror = null;
556
- reject(error);
592
+ callback();
557
593
  };
594
+ timerId = setTimeout(() => {
595
+ settle(() => reject(new Error("Image load timed out")));
596
+ try {
597
+ imageElement.src = "";
598
+ } catch (error) {
599
+ void error;
600
+ }
601
+ }, safeTimeoutMs);
602
+ imageElement.onload = () => settle(() => resolve(imageElement));
603
+ imageElement.onerror = (error) => settle(() => reject(error));
558
604
  imageElement.src = dataUrl;
559
605
  });
560
606
  }
@@ -573,6 +619,7 @@ var ImageEditor = class {
573
619
  offscreenCanvas.width = targetWidth;
574
620
  offscreenCanvas.height = targetHeight;
575
621
  const context = offscreenCanvas.getContext("2d");
622
+ if (!context) throw new Error("2D canvas context is unavailable");
576
623
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
577
624
  return offscreenCanvas.toDataURL("image/jpeg", quality);
578
625
  }
@@ -580,28 +627,26 @@ var ImageEditor = class {
580
627
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
581
628
  * Also updates the corresponding style attributes.
582
629
  *
583
- * @param {number} w - Canvas width (in pixels).
584
- * @param {number} h - Canvas height (in pixels).
630
+ * @param {number} width - Canvas width in pixels.
631
+ * @param {number} height - Canvas height in pixels.
585
632
  * @private
586
633
  */
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);
592
- if (typeof this.canvas.calcOffset === "function")
593
- this.canvas.calcOffset();
634
+ _setCanvasSizeInt(width, height) {
635
+ const integerWidth = Math.max(1, Math.round(Number(width) || 1));
636
+ const integerHeight = Math.max(1, Math.round(Number(height) || 1));
637
+ this.canvas.setWidth(integerWidth);
638
+ this.canvas.setHeight(integerHeight);
639
+ if (typeof this.canvas.calcOffset === "function") this.canvas.calcOffset();
594
640
  if (this.canvasElement) {
595
- this.canvasElement.style.width = iw + "px";
596
- this.canvasElement.style.height = ih + "px";
641
+ this.canvasElement.style.width = integerWidth + "px";
642
+ this.canvasElement.style.height = integerHeight + "px";
597
643
  this.canvasElement.style.maxWidth = "none";
598
644
  }
599
645
  }
600
646
  _ceilCanvasDimension(value) {
601
647
  const numericValue = Number(value) || 0;
602
648
  const roundedValue = Math.round(numericValue);
603
- if (Math.abs(numericValue - roundedValue) < 0.01)
604
- return roundedValue;
649
+ if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
605
650
  return Math.ceil(numericValue);
606
651
  }
607
652
  _getContainerViewportSize() {
@@ -611,22 +656,31 @@ var ImageEditor = class {
611
656
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
612
657
  };
613
658
  }
659
+ let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
660
+ let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
614
661
  if (this._hasFixedContainerScrollbars()) {
615
- return {
616
- width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
617
- height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
618
- };
662
+ return { width, height };
663
+ }
664
+ const overflow = this._getContainerOverflowValues();
665
+ const canScrollX = overflow.x.some((value) => value === "auto" || value === "scroll");
666
+ const canScrollY = overflow.y.some((value) => value === "auto" || value === "scroll");
667
+ const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
668
+ const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
669
+ if (hasHorizontalScrollbar || hasVerticalScrollbar) {
670
+ const scrollbar = this._getScrollbarSize();
671
+ if (hasVerticalScrollbar) width += scrollbar.width;
672
+ if (hasHorizontalScrollbar) height += scrollbar.height;
619
673
  }
620
- const previousOverflow = this.containerElement.style.overflow;
621
- this.containerElement.style.overflow = "hidden";
622
- const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
623
- const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
624
- this.containerElement.style.overflow = previousOverflow;
625
674
  return { width, height };
626
675
  }
627
- _hasFixedContainerScrollbars() {
628
- if (!this.containerElement)
629
- return false;
676
+ /**
677
+ * Reads inline and computed overflow values for both scroll axes.
678
+ *
679
+ * @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
680
+ * @private
681
+ */
682
+ _getContainerOverflowValues() {
683
+ if (!this.containerElement) return { x: [], y: [] };
630
684
  const inlineOverflow = this.containerElement.style.overflow;
631
685
  const inlineOverflowX = this.containerElement.style.overflowX;
632
686
  const inlineOverflowY = this.containerElement.style.overflowY;
@@ -639,9 +693,20 @@ var ImageEditor = class {
639
693
  computedOverflowX = style.overflowX;
640
694
  computedOverflowY = style.overflowY;
641
695
  }
642
- return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
696
+ return {
697
+ x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
698
+ y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
699
+ };
700
+ }
701
+ _hasFixedContainerScrollbars() {
702
+ if (!this.containerElement) return false;
703
+ const overflow = this._getContainerOverflowValues();
704
+ return [...overflow.x, ...overflow.y].some((value) => value === "scroll");
643
705
  }
644
706
  _getScrollbarSize() {
707
+ if (this._scrollbarSizeCache) {
708
+ return { ...this._scrollbarSizeCache };
709
+ }
645
710
  if (typeof document === "undefined" || !document.createElement || !document.body) {
646
711
  return { width: 0, height: 0 };
647
712
  }
@@ -656,7 +721,8 @@ var ImageEditor = class {
656
721
  const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
657
722
  const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
658
723
  document.body.removeChild(probe);
659
- return { width, height };
724
+ this._scrollbarSizeCache = { width, height };
725
+ return { ...this._scrollbarSizeCache };
660
726
  }
661
727
  _getScrollSafetyMargin() {
662
728
  return 2;
@@ -678,15 +744,14 @@ var ImageEditor = class {
678
744
  const scrollbar = this._getScrollbarSize();
679
745
  let hasVertical = false;
680
746
  let hasHorizontal = false;
681
- let effectiveWidth = viewport.width;
682
- let effectiveHeight = viewport.height;
747
+ let effectiveWidth;
748
+ let effectiveHeight;
683
749
  for (let i = 0; i < 4; i += 1) {
684
750
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
685
751
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
686
752
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
687
753
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
688
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
689
- break;
754
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
690
755
  hasVertical = nextHasVertical;
691
756
  hasHorizontal = nextHasHorizontal;
692
757
  }
@@ -723,8 +788,8 @@ var ImageEditor = class {
723
788
  let scale = 1;
724
789
  let contentWidth = imageWidth;
725
790
  let contentHeight = imageHeight;
726
- let effectiveWidth = viewport.width;
727
- let effectiveHeight = viewport.height;
791
+ let effectiveWidth;
792
+ let effectiveHeight;
728
793
  for (let i = 0; i < 4; i += 1) {
729
794
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
730
795
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
@@ -733,8 +798,7 @@ var ImageEditor = class {
733
798
  contentHeight = imageHeight * scale;
734
799
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
735
800
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
736
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
737
- break;
801
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
738
802
  hasVertical = nextHasVertical;
739
803
  hasHorizontal = nextHasHorizontal;
740
804
  }
@@ -773,41 +837,48 @@ var ImageEditor = class {
773
837
  stroke: mask && mask.originalStroke || "#ccc",
774
838
  strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
775
839
  };
776
- if (Number.isFinite(opacity))
777
- style.opacity = opacity;
840
+ if (Number.isFinite(opacity)) style.opacity = opacity;
778
841
  return style;
779
842
  }
780
843
  _withNormalizedMaskStyles(callback) {
781
- if (!this.canvas)
782
- return callback();
844
+ if (!this.canvas) return callback();
783
845
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
784
- const maskStyleBackups = masks.map((mask) => ({
785
- object: mask,
786
- stroke: mask.stroke,
787
- strokeWidth: mask.strokeWidth,
788
- opacity: mask.opacity
789
- }));
846
+ const maskStyleBackups = [];
790
847
  try {
791
848
  masks.forEach((mask) => {
792
- mask.set(this._getMaskNormalStyle(mask));
849
+ const normalStyle = this._getMaskNormalStyle(mask);
850
+ const stylePatch = {};
851
+ Object.keys(normalStyle).forEach((property) => {
852
+ if (mask[property] !== normalStyle[property]) {
853
+ stylePatch[property] = normalStyle[property];
854
+ }
855
+ });
856
+ const changedProperties = Object.keys(stylePatch);
857
+ if (!changedProperties.length) return;
858
+ const backup = { object: mask };
859
+ changedProperties.forEach((property) => {
860
+ backup[property] = mask[property];
861
+ });
862
+ maskStyleBackups.push(backup);
863
+ mask.set(stylePatch);
793
864
  });
794
865
  return callback();
795
866
  } finally {
796
867
  maskStyleBackups.forEach((backup) => {
797
868
  try {
798
- backup.object.set({
799
- stroke: backup.stroke,
800
- strokeWidth: backup.strokeWidth,
801
- opacity: backup.opacity
869
+ const restorePatch = {};
870
+ Object.keys(backup).forEach((property) => {
871
+ if (property !== "object") restorePatch[property] = backup[property];
802
872
  });
873
+ backup.object.set(restorePatch);
803
874
  } catch (error) {
875
+ void error;
804
876
  }
805
877
  });
806
878
  }
807
879
  }
808
880
  _restoreMaskControls(mask) {
809
- if (!mask)
810
- return;
881
+ if (!mask) return;
811
882
  const cornerSize = Number(mask.cornerSize);
812
883
  mask.set({
813
884
  selectable: mask.selectable !== false,
@@ -820,26 +891,57 @@ var ImageEditor = class {
820
891
  transparentCorners: mask.transparentCorners === true,
821
892
  strokeUniform: mask.strokeUniform !== false
822
893
  });
823
- if (typeof mask.setCoords === "function")
824
- mask.setCoords();
894
+ if (typeof mask.setCoords === "function") mask.setCoords();
895
+ }
896
+ /**
897
+ * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
898
+ *
899
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
900
+ * @private
901
+ */
902
+ _serializeEditorMetadata() {
903
+ const baseImageScale = Number(this.baseImageScale);
904
+ const currentScale = Number(this.currentScale);
905
+ const currentRotation = Number(this.currentRotation);
906
+ const maskCounter = Number(this.maskCounter);
907
+ return {
908
+ version: 1,
909
+ baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
910
+ currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
911
+ currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
912
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
913
+ };
825
914
  }
826
915
  _serializeCanvasState() {
827
- if (!this.canvas)
828
- return null;
916
+ if (!this.canvas) return null;
829
917
  return this._withNormalizedMaskStyles(() => {
830
918
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
831
919
  if (Array.isArray(jsonObject.objects)) {
832
920
  jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
833
921
  }
922
+ jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
834
923
  return JSON.stringify(jsonObject);
835
924
  });
836
925
  }
926
+ /**
927
+ * Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
928
+ *
929
+ * @param {number} quality - Requested image quality.
930
+ * @returns {number} A finite quality value between 0 and 1.
931
+ * @private
932
+ */
837
933
  _normalizeQuality(quality) {
838
934
  const numericQuality = Number(quality);
839
- if (!Number.isFinite(numericQuality))
840
- return this.options.downsampleQuality ?? 0.92;
935
+ if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
841
936
  return Math.max(0, Math.min(1, numericQuality));
842
937
  }
938
+ /**
939
+ * Normalizes public image format aliases to canvas export format names.
940
+ *
941
+ * @param {string} format - Requested image format or MIME type.
942
+ * @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
943
+ * @private
944
+ */
843
945
  _normalizeImageFormat(format) {
844
946
  const typeMapping = {
845
947
  "jpeg": "jpeg",
@@ -852,6 +954,15 @@ var ImageEditor = class {
852
954
  };
853
955
  return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
854
956
  }
957
+ /**
958
+ * Converts a bounding rectangle into a canvas-safe integer source region.
959
+ *
960
+ * @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
961
+ * @param {Object} [options={}] - Region rounding options.
962
+ * @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
963
+ * @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
964
+ * @private
965
+ */
855
966
  _getClampedCanvasRegion(bounds, options = {}) {
856
967
  const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
857
968
  const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
@@ -866,15 +977,49 @@ var ImageEditor = class {
866
977
  const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
867
978
  const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
868
979
  return {
869
- sx: sourceX,
870
- sy: sourceY,
871
- sw: Math.max(1, endX - sourceX),
872
- sh: Math.max(1, endY - sourceY)
980
+ sourceX,
981
+ sourceY,
982
+ sourceWidth: Math.max(1, endX - sourceX),
983
+ sourceHeight: Math.max(1, endY - sourceY)
873
984
  };
874
985
  }
986
+ /**
987
+ * Crops an image data URL to a source region using an offscreen canvas.
988
+ *
989
+ * @param {string} dataUrl - Source image data URL.
990
+ * @param {number} sourceX - Source region x coordinate.
991
+ * @param {number} sourceY - Source region y coordinate.
992
+ * @param {number} sourceWidth - Source region width.
993
+ * @param {number} sourceHeight - Source region height.
994
+ * @param {number} multiplier - Export multiplier already applied to the source data URL.
995
+ * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
996
+ * @param {number} [quality=0.92] - Output image quality for lossy formats.
997
+ * @returns {Promise<string>} Resolves with the cropped image data URL.
998
+ * @private
999
+ */
875
1000
  async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
876
1001
  return new Promise((resolve, reject) => {
877
1002
  const imageElement = new Image();
1003
+ let isSettled = false;
1004
+ const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1005
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1006
+ let timerId;
1007
+ const settle = (callback) => {
1008
+ if (isSettled) return;
1009
+ isSettled = true;
1010
+ clearTimeout(timerId);
1011
+ imageElement.onload = null;
1012
+ imageElement.onerror = null;
1013
+ callback();
1014
+ };
1015
+ timerId = setTimeout(() => {
1016
+ settle(() => reject(new Error("Image crop load timed out")));
1017
+ try {
1018
+ imageElement.src = "";
1019
+ } catch (error) {
1020
+ void error;
1021
+ }
1022
+ }, safeTimeoutMs);
878
1023
  imageElement.onload = () => {
879
1024
  try {
880
1025
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
@@ -886,24 +1031,39 @@ var ImageEditor = class {
886
1031
  offscreenCanvas.width = scaledSourceWidth;
887
1032
  offscreenCanvas.height = scaledSourceHeight;
888
1033
  const context = offscreenCanvas.getContext("2d");
1034
+ if (!context) throw new Error("2D canvas context is unavailable");
889
1035
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
890
- resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
1036
+ settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
891
1037
  } catch (error) {
892
- reject(error);
1038
+ settle(() => reject(error));
893
1039
  }
894
1040
  };
895
- imageElement.onerror = reject;
1041
+ imageElement.onerror = (error) => settle(() => reject(error));
896
1042
  imageElement.src = dataUrl;
897
1043
  });
898
1044
  }
899
- async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1045
+ /**
1046
+ * Exports the whole Fabric canvas, then crops the requested source region from that export.
1047
+ *
1048
+ * @param {Object} region - Canvas source region and export options.
1049
+ * @param {number} region.sourceX - Source region x coordinate.
1050
+ * @param {number} region.sourceY - Source region y coordinate.
1051
+ * @param {number} region.sourceWidth - Source region width.
1052
+ * @param {number} region.sourceHeight - Source region height.
1053
+ * @param {number} [region.multiplier=1] - Export multiplier.
1054
+ * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1055
+ * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1056
+ * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1057
+ * @private
1058
+ */
1059
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
900
1060
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
901
1061
  const fullDataUrl = this.canvas.toDataURL({
902
1062
  format,
903
1063
  quality,
904
1064
  multiplier: safeMultiplier
905
1065
  });
906
- return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
1066
+ return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
907
1067
  }
908
1068
  /**
909
1069
  * Gets the top-left corner coordinates of the given object.
@@ -914,12 +1074,10 @@ var ImageEditor = class {
914
1074
  * @private
915
1075
  */
916
1076
  _getObjectTopLeftPoint(fabricObject) {
917
- if (!fabricObject)
918
- return { x: 0, y: 0 };
1077
+ if (!fabricObject) return { x: 0, y: 0 };
919
1078
  fabricObject.setCoords();
920
1079
  const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
921
- if (coords && coords.length)
922
- return coords[0];
1080
+ if (coords && coords.length) return coords[0];
923
1081
  const boundingRect = fabricObject.getBoundingRect(true, true);
924
1082
  return { x: boundingRect.left, y: boundingRect.top };
925
1083
  }
@@ -933,8 +1091,7 @@ var ImageEditor = class {
933
1091
  * @private
934
1092
  */
935
1093
  _setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
936
- if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
937
- return;
1094
+ if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
938
1095
  fabricObject.set({ originX, originY });
939
1096
  fabricObject.setPositionByOrigin(refPoint, originX, originY);
940
1097
  fabricObject.setCoords();
@@ -946,8 +1103,7 @@ var ImageEditor = class {
946
1103
  * @private
947
1104
  */
948
1105
  _alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
949
- if (!fabricObject)
950
- return;
1106
+ if (!fabricObject) return;
951
1107
  fabricObject.setCoords();
952
1108
  const boundingRect = fabricObject.getBoundingRect(true, true);
953
1109
  const deltaX = boundingRect.left;
@@ -962,30 +1118,63 @@ var ImageEditor = class {
962
1118
  * @private
963
1119
  */
964
1120
  _updateCanvasSizeToImageBounds() {
965
- if (!this.originalImage)
966
- return;
1121
+ if (!this.originalImage) return;
967
1122
  this.originalImage.setCoords();
968
1123
  const imageBounds = this.originalImage.getBoundingRect(true, true);
969
1124
  const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
970
1125
  this._setCanvasSizeInt(size.width, size.height);
971
1126
  }
972
- _expandCanvasToFitObject(fabricObject, padding = 10) {
973
- if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage)
974
- return;
1127
+ /**
1128
+ * Whether post-load edits should resize the canvas to keep transformed content visible.
1129
+ *
1130
+ * @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
1131
+ * @private
1132
+ */
1133
+ _shouldResizeCanvasToContentBounds() {
1134
+ return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
1135
+ }
1136
+ /**
1137
+ * Expands the canvas once so all provided objects remain visible after an edit.
1138
+ *
1139
+ * @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
1140
+ * @param {number} [padding=10] - Extra canvas space after the farthest object edge.
1141
+ * @returns {void}
1142
+ * @private
1143
+ */
1144
+ _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1145
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
975
1146
  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);
1147
+ let requiredWidth = this.canvas.getWidth();
1148
+ let requiredHeight = this.canvas.getHeight();
1149
+ fabricObjects.forEach((fabricObject) => {
1150
+ if (!fabricObject) return;
1151
+ if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1152
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1153
+ requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1154
+ requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1155
+ });
980
1156
  const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
981
1157
  const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
982
1158
  const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
983
1159
  const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
984
- this._setCanvasSizeInt(newWidth, newHeight);
1160
+ if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1161
+ this._setCanvasSizeInt(newWidth, newHeight);
1162
+ }
985
1163
  } catch (error) {
986
- this._reportWarning("expandCanvasToFitObject: failed to expand canvas", error);
1164
+ this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
987
1165
  }
988
1166
  }
1167
+ /**
1168
+ * Expands the canvas so one object remains visible after an edit.
1169
+ *
1170
+ * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1171
+ * @param {number} [padding=10] - Extra canvas space after the object edge.
1172
+ * @returns {void}
1173
+ * @private
1174
+ */
1175
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1176
+ this._expandCanvasToFitObjects([fabricObject], padding);
1177
+ }
989
1178
  /**
990
1179
  * Scales the original image by a given factor, with animation.
991
1180
  * Returns a promise that resolves when the scale animation is complete.
@@ -994,7 +1183,7 @@ var ImageEditor = class {
994
1183
  * @public
995
1184
  */
996
1185
  scaleImage(factor, options = {}) {
997
- return this.animQueue.add(() => this._scaleImageImpl(factor, options));
1186
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
998
1187
  }
999
1188
  /**
1000
1189
  * Scales the original image by a given factor, with animation.
@@ -1004,10 +1193,8 @@ var ImageEditor = class {
1004
1193
  * @private
1005
1194
  */
1006
1195
  _scaleImageImpl(factor, options = {}) {
1007
- if (!this.originalImage)
1008
- return Promise.resolve();
1009
- if (this.isAnimating)
1010
- return Promise.resolve();
1196
+ if (!this.originalImage) return Promise.resolve();
1197
+ if (this.isAnimating) return Promise.resolve();
1011
1198
  const saveHistory = options.saveHistory !== false;
1012
1199
  factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1013
1200
  this.currentScale = factor;
@@ -1033,19 +1220,17 @@ var ImageEditor = class {
1033
1220
  return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1034
1221
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1035
1222
  this.originalImage.setCoords();
1036
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1223
+ if (this._shouldResizeCanvasToContentBounds()) {
1037
1224
  this._updateCanvasSizeToImageBounds();
1038
1225
  }
1039
1226
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1040
1227
  this.canvas.getObjects().forEach((object) => {
1041
- if (object.maskId)
1042
- this._syncMaskLabel(object);
1228
+ if (object.maskId) this._syncMaskLabel(object);
1043
1229
  });
1044
1230
  this.isAnimating = false;
1045
1231
  this._updateInputs();
1046
1232
  this._updateUI();
1047
- if (saveHistory)
1048
- this.saveState();
1233
+ if (saveHistory) this.saveState();
1049
1234
  }).catch(() => {
1050
1235
  this.isAnimating = false;
1051
1236
  this._updateUI();
@@ -1059,7 +1244,7 @@ var ImageEditor = class {
1059
1244
  * @public
1060
1245
  */
1061
1246
  rotateImage(degrees, options = {}) {
1062
- return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
1247
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1063
1248
  }
1064
1249
  /**
1065
1250
  * Rotates the original image by a given number of degrees, with animation.
@@ -1069,12 +1254,9 @@ var ImageEditor = class {
1069
1254
  * @private
1070
1255
  */
1071
1256
  _rotateImageImpl(degrees, options = {}) {
1072
- if (!this.originalImage)
1073
- return Promise.resolve();
1074
- if (this.isAnimating)
1075
- return Promise.resolve();
1076
- if (isNaN(degrees))
1077
- return Promise.resolve();
1257
+ if (!this.originalImage) return Promise.resolve();
1258
+ if (this.isAnimating) return Promise.resolve();
1259
+ if (isNaN(degrees)) return Promise.resolve();
1078
1260
  const saveHistory = options.saveHistory !== false;
1079
1261
  this.currentRotation = degrees;
1080
1262
  this.isAnimating = true;
@@ -1091,21 +1273,19 @@ var ImageEditor = class {
1091
1273
  return rotationAnimation.then(() => {
1092
1274
  this.originalImage.set("angle", degrees);
1093
1275
  this.originalImage.setCoords();
1094
- if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1276
+ if (this._shouldResizeCanvasToContentBounds()) {
1095
1277
  this._updateCanvasSizeToImageBounds();
1096
1278
  }
1097
1279
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1098
1280
  const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1099
1281
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1100
1282
  this.canvas.getObjects().forEach((object) => {
1101
- if (object.maskId)
1102
- this._syncMaskLabel(object);
1283
+ if (object.maskId) this._syncMaskLabel(object);
1103
1284
  });
1104
1285
  this.isAnimating = false;
1105
1286
  this._updateInputs();
1106
1287
  this._updateUI();
1107
- if (saveHistory)
1108
- this.saveState();
1288
+ if (saveHistory) this.saveState();
1109
1289
  }).catch(() => {
1110
1290
  this.isAnimating = false;
1111
1291
  this._updateUI();
@@ -1113,38 +1293,45 @@ var ImageEditor = class {
1113
1293
  }
1114
1294
  /**
1115
1295
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
1116
- * @returns {Promise<void>} Promise that resolves when reset is complete.
1296
+ *
1297
+ * @returns {Promise<void>} Resolves when the reset history transition has been recorded.
1298
+ * @public
1117
1299
  */
1118
1300
  resetImageTransform() {
1119
- if (!this.originalImage)
1120
- return Promise.resolve();
1121
- return this.animQueue.add(async () => {
1122
- const before = this._serializeCanvasState();
1301
+ if (!this.originalImage) return Promise.resolve();
1302
+ return this.animationQueue.add(async () => {
1303
+ const before = this._lastSnapshot || this._serializeCanvasState();
1123
1304
  await this._scaleImageImpl(1, { saveHistory: false });
1124
1305
  await this._rotateImageImpl(0, { saveHistory: false });
1125
1306
  const after = this._serializeCanvasState();
1126
1307
  this._pushStateTransition(before, after);
1127
- }).catch((err) => {
1128
- this._reportError("resetImageTransform() failed", err);
1308
+ }).catch((error) => {
1309
+ this._reportError("resetImageTransform() failed", error);
1129
1310
  });
1130
1311
  }
1131
1312
  /**
1132
- * @deprecated Use resetImageTransform() instead.
1313
+ * Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
1314
+ *
1315
+ * @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
1316
+ * @returns {Promise<void>} Resolves when the image transform reset is complete.
1133
1317
  */
1134
1318
  reset() {
1135
1319
  return this.resetImageTransform();
1136
1320
  }
1137
1321
  /**
1138
- * Restores a canvas state that was previously stored by saveState().
1139
- * @param {string} jsonString - the JSON string returned by fabric.toJSON().
1322
+ * Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
1323
+ *
1324
+ * @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
1325
+ * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1326
+ * @public
1140
1327
  */
1141
- loadFromState(jsonString) {
1142
- if (!jsonString || !this.canvas)
1143
- return Promise.resolve();
1328
+ loadFromState(serializedState) {
1329
+ if (!serializedState || !this.canvas) return Promise.resolve();
1144
1330
  return new Promise((resolve) => {
1145
1331
  try {
1146
- const json = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
1147
- this.canvas.loadFromJSON(json, () => {
1332
+ const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1333
+ const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1334
+ this.canvas.loadFromJSON(state, () => {
1148
1335
  try {
1149
1336
  this._hideAllMaskLabels();
1150
1337
  const canvasObjects = this.canvas.getObjects();
@@ -1152,11 +1339,22 @@ var ImageEditor = class {
1152
1339
  if (this.originalImage) {
1153
1340
  this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
1154
1341
  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;
1342
+ const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
1343
+ const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
1344
+ const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
1345
+ if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
1346
+ this.baseImageScale = restoredBaseScale;
1347
+ }
1348
+ if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
1349
+ this.currentScale = restoredCurrentScale;
1350
+ } else {
1351
+ const baseScale = Number(this.baseImageScale) || 1;
1352
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1353
+ this.currentScale = imageScale / baseScale;
1354
+ }
1355
+ this.currentRotation = Number.isFinite(restoredCurrentRotation) ? restoredCurrentRotation : Number(this.originalImage.angle) || 0;
1159
1356
  } else {
1357
+ this.baseImageScale = 1;
1160
1358
  this.currentScale = 1;
1161
1359
  this.currentRotation = 0;
1162
1360
  }
@@ -1166,7 +1364,9 @@ var ImageEditor = class {
1166
1364
  this._rebindMaskEvents(mask);
1167
1365
  mask.set(this._getMaskNormalStyle(mask));
1168
1366
  });
1169
- this.maskCounter = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
1367
+ const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
1368
+ const maxMaskId = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
1369
+ this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId ? Math.floor(restoredMaskCounter) : maxMaskId;
1170
1370
  this._lastMask = masks.length ? masks[masks.length - 1] : null;
1171
1371
  if (!this._lastMask) {
1172
1372
  this._lastMaskInitialLeft = null;
@@ -1193,18 +1393,21 @@ var ImageEditor = class {
1193
1393
  });
1194
1394
  }
1195
1395
  /**
1196
- * Saves the current state of the canvas to history, storing any mask/raster label information.
1396
+ * Saves the current editable canvas state as an undoable history transition.
1397
+ *
1398
+ * Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
1399
+ * mask objects and restored by `loadFromState()`.
1400
+ *
1401
+ * @returns {void}
1402
+ * @public
1197
1403
  */
1198
1404
  saveState() {
1199
- if (!this.canvas)
1200
- return;
1405
+ if (!this.canvas) return;
1201
1406
  const activeObject = this.canvas.getActiveObject();
1202
- this._hideAllMaskLabels();
1203
1407
  try {
1204
1408
  const after = this._serializeCanvasState();
1205
1409
  const before = this._lastSnapshot || after;
1206
- if (after === before)
1207
- return;
1410
+ if (after === before) return;
1208
1411
  let executedOnce = false;
1209
1412
  const command = new Command(
1210
1413
  () => {
@@ -1221,19 +1424,27 @@ var ImageEditor = class {
1221
1424
  } catch (error) {
1222
1425
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1223
1426
  } finally {
1224
- if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
1427
+ if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1225
1428
  this._handleSelectionChanged([activeObject]);
1226
1429
  }
1227
1430
  this._updateUI();
1228
1431
  }
1229
1432
  }
1433
+ /**
1434
+ * Pushes a precomputed before/after state transition into history.
1435
+ *
1436
+ * Use this for operations such as crop and merge that build their snapshots around asynchronous image
1437
+ * loading, where the "after" state is already applied before the history command is recorded.
1438
+ *
1439
+ * @param {string} before - Serialized state before the operation.
1440
+ * @param {string} after - Serialized state after the operation.
1441
+ * @returns {void}
1442
+ * @private
1443
+ */
1230
1444
  _pushStateTransition(before, after) {
1231
- if (!before || !after)
1232
- return;
1233
- if (before === after)
1234
- return;
1235
- if (!this.historyManager)
1236
- this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1445
+ if (!before || !after) return;
1446
+ if (before === after) return;
1447
+ if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1237
1448
  const command = new Command(
1238
1449
  () => this.loadFromState(after),
1239
1450
  () => this.loadFromState(before)
@@ -1244,6 +1455,9 @@ var ImageEditor = class {
1244
1455
  }
1245
1456
  /**
1246
1457
  * Undo the last state change, if possible.
1458
+ *
1459
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
1460
+ * @public
1247
1461
  */
1248
1462
  undo() {
1249
1463
  return this.historyManager.undo().then(() => {
@@ -1254,6 +1468,9 @@ var ImageEditor = class {
1254
1468
  }
1255
1469
  /**
1256
1470
  * Redo the next state change, if possible.
1471
+ *
1472
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
1473
+ * @public
1257
1474
  */
1258
1475
  redo() {
1259
1476
  return this.historyManager.redo().then(() => {
@@ -1263,26 +1480,24 @@ var ImageEditor = class {
1263
1480
  });
1264
1481
  }
1265
1482
  _rebindMaskEvents(mask) {
1266
- if (!mask)
1267
- return;
1483
+ if (!mask) return;
1268
1484
  if (mask.__imageEditorMaskHandlers) {
1269
1485
  try {
1270
1486
  mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
1271
1487
  mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
1272
- } catch (e) {
1488
+ } catch (error) {
1489
+ void error;
1273
1490
  }
1274
1491
  }
1275
1492
  const metadata = {};
1276
1493
  if (!Number.isFinite(Number(mask.originalAlpha))) {
1277
1494
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
1278
1495
  }
1279
- if (!mask.originalStroke)
1280
- metadata.originalStroke = mask.stroke || "#ccc";
1496
+ if (!mask.originalStroke) metadata.originalStroke = mask.stroke || "#ccc";
1281
1497
  if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
1282
1498
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1283
1499
  }
1284
- if (Object.keys(metadata).length)
1285
- mask.set(metadata);
1500
+ if (Object.keys(metadata).length) mask.set(metadata);
1286
1501
  const normalStyle = {
1287
1502
  stroke: mask.originalStroke || "#ccc",
1288
1503
  strokeWidth: mask.originalStrokeWidth,
@@ -1295,40 +1510,46 @@ var ImageEditor = class {
1295
1510
  };
1296
1511
  const mouseover = () => {
1297
1512
  mask.set(hoverStyle);
1298
- if (mask.canvas)
1299
- mask.canvas.requestRenderAll();
1513
+ if (mask.canvas) mask.canvas.requestRenderAll();
1300
1514
  };
1301
1515
  const mouseout = () => {
1302
1516
  mask.set(normalStyle);
1303
- if (mask.canvas)
1304
- mask.canvas.requestRenderAll();
1517
+ if (mask.canvas) mask.canvas.requestRenderAll();
1305
1518
  };
1306
1519
  mask.on("mouseover", mouseover);
1307
1520
  mask.on("mouseout", mouseout);
1308
1521
  mask.__imageEditorMaskHandlers = { mouseover, mouseout };
1309
1522
  }
1310
- /**
1523
+ /**
1311
1524
  * 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.
1525
+ *
1526
+ * Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
1527
+ * after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
1528
+ * so controls and hit testing stay in sync with Fabric 5.x behavior.
1529
+ *
1530
+ * @param {Object} [config={}] - Optional mask configuration overrides.
1531
+ * @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
1532
+ * @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
1533
+ * @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
1534
+ * @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
1535
+ * @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
1536
+ * @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
1537
+ * @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
1538
+ * @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
1539
+ * @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
1540
+ * @param {number} [config.angle=0] - Rotation angle in degrees.
1541
+ * @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
1542
+ * @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
1543
+ * @param {boolean} [config.selectable=true] - Whether the mask can be selected.
1544
+ * @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
1545
+ * @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
1546
+ * @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
1547
+ * @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
1548
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1327
1549
  * @public
1328
1550
  */
1329
1551
  createMask(config = {}) {
1330
- if (!this.canvas)
1331
- return null;
1552
+ if (!this.canvas) return null;
1332
1553
  const shapeType = config.shape || "rect";
1333
1554
  const maskConfig = {
1334
1555
  shape: shapeType,
@@ -1344,14 +1565,22 @@ var ImageEditor = class {
1344
1565
  ...config
1345
1566
  };
1346
1567
  const firstOffset = 10;
1347
- let left = firstOffset;
1348
- let top = firstOffset;
1349
- const resolveValue = (value, fallback) => {
1568
+ let left;
1569
+ let top;
1570
+ const getCanvasBasis = (axis) => {
1571
+ const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
1572
+ const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
1573
+ if (axis === "height") return canvasHeight;
1574
+ if (axis === "min") return Math.min(canvasWidth, canvasHeight);
1575
+ return canvasWidth;
1576
+ };
1577
+ const resolveValue = (value, fallback, axis = "width") => {
1350
1578
  if (typeof value === "function")
1351
1579
  return value(this.canvas, this.options);
1352
1580
  if (typeof value === "string" && value.endsWith("%")) {
1353
- const percent = parseFloat(value) / 100;
1354
- return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1581
+ const percent = Number.parseFloat(value) / 100;
1582
+ if (!Number.isFinite(percent)) return fallback;
1583
+ return Math.floor(getCanvasBasis(axis) * percent);
1355
1584
  }
1356
1585
  return value != null ? value : fallback;
1357
1586
  };
@@ -1366,11 +1595,13 @@ var ImageEditor = class {
1366
1595
  left = Math.round(previousMaskRight + maskConfig.gap);
1367
1596
  top = previousMask.top ?? firstOffset;
1368
1597
  } else {
1369
- left = resolveValue(maskConfig.left, firstOffset);
1370
- top = resolveValue(maskConfig.top, firstOffset);
1598
+ left = resolveValue(maskConfig.left, firstOffset, "width");
1599
+ top = resolveValue(maskConfig.top, firstOffset, "height");
1371
1600
  }
1372
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1373
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1601
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1602
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
1603
+ maskConfig.left = left;
1604
+ maskConfig.top = top;
1374
1605
  let mask;
1375
1606
  if (typeof maskConfig.fabricGenerator === "function") {
1376
1607
  mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
@@ -1380,7 +1611,7 @@ var ImageEditor = class {
1380
1611
  mask = new fabric.Circle({
1381
1612
  left,
1382
1613
  top,
1383
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1614
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
1384
1615
  fill: maskConfig.color,
1385
1616
  opacity: maskConfig.alpha,
1386
1617
  angle: maskConfig.angle,
@@ -1391,8 +1622,8 @@ var ImageEditor = class {
1391
1622
  mask = new fabric.Ellipse({
1392
1623
  left,
1393
1624
  top,
1394
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1395
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1625
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
1626
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
1396
1627
  fill: maskConfig.color,
1397
1628
  opacity: maskConfig.alpha,
1398
1629
  angle: maskConfig.angle,
@@ -1401,8 +1632,8 @@ var ImageEditor = class {
1401
1632
  break;
1402
1633
  case "polygon": {
1403
1634
  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) }));
1635
+ if (Array.isArray(polygonPoints) && polygonPoints.length) {
1636
+ polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
1406
1637
  }
1407
1638
  mask = new fabric.Polygon(polygonPoints, {
1408
1639
  left,
@@ -1419,13 +1650,12 @@ var ImageEditor = class {
1419
1650
  mask = new fabric.Rect({
1420
1651
  left,
1421
1652
  top,
1422
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1423
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1653
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
1654
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
1424
1655
  fill: maskConfig.color,
1425
1656
  opacity: maskConfig.alpha,
1426
1657
  angle: maskConfig.angle,
1427
1658
  rx: maskConfig.rx,
1428
- // Rounded Corners
1429
1659
  ry: maskConfig.ry,
1430
1660
  ...maskConfig.styles
1431
1661
  });
@@ -1443,14 +1673,14 @@ var ImageEditor = class {
1443
1673
  transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
1444
1674
  stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
1445
1675
  strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
1676
+ opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
1446
1677
  strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
1447
1678
  };
1448
- if (hasStyle("strokeDashArray"))
1449
- maskSettings.strokeDashArray = styles.strokeDashArray;
1679
+ if (hasStyle("strokeDashArray")) maskSettings.strokeDashArray = styles.strokeDashArray;
1450
1680
  mask.set(maskSettings);
1451
1681
  mask.setCoords();
1452
1682
  mask.set({
1453
- originalAlpha: maskConfig.alpha,
1683
+ originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
1454
1684
  originalStroke: mask.stroke || "#ccc",
1455
1685
  originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1456
1686
  });
@@ -1458,7 +1688,7 @@ var ImageEditor = class {
1458
1688
  this._expandCanvasToFitObject(mask);
1459
1689
  this._lastMaskInitialLeft = left;
1460
1690
  this._lastMaskInitialTop = top;
1461
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1691
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1462
1692
  const maskId = ++this.maskCounter;
1463
1693
  mask.set({
1464
1694
  maskId,
@@ -1467,19 +1697,21 @@ var ImageEditor = class {
1467
1697
  this._lastMask = mask;
1468
1698
  this.canvas.add(mask);
1469
1699
  this.canvas.bringToFront(mask);
1470
- if (maskConfig.selectable)
1471
- this.canvas.setActiveObject(mask);
1700
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1472
1701
  this._handleSelectionChanged([mask]);
1473
1702
  this._updateMaskList();
1474
1703
  this._updateUI();
1475
1704
  this.canvas.renderAll();
1476
1705
  this.saveState();
1477
- if (typeof maskConfig.onCreate === "function")
1478
- maskConfig.onCreate(mask, this.canvas);
1706
+ if (typeof maskConfig.onCreate === "function") maskConfig.onCreate(mask, this.canvas);
1479
1707
  return mask;
1480
1708
  }
1481
1709
  /**
1482
- * @deprecated Use createMask() instead.
1710
+ * Backward-compatible alias for {@link ImageEditor#createMask}.
1711
+ *
1712
+ * @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
1713
+ * @param {Object} [config={}] - Mask configuration passed to createMask().
1714
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1483
1715
  */
1484
1716
  addMask(config = {}) {
1485
1717
  return this.createMask(config);
@@ -1491,8 +1723,7 @@ var ImageEditor = class {
1491
1723
  removeSelectedMask() {
1492
1724
  const activeObject = this.canvas.getActiveObject();
1493
1725
  const selectedMasks = this._getModifiedMasks(activeObject);
1494
- if (!selectedMasks.length)
1495
- return;
1726
+ if (!selectedMasks.length) return;
1496
1727
  this.canvas.discardActiveObject();
1497
1728
  selectedMasks.forEach((mask) => {
1498
1729
  this._removeLabelForMask(mask);
@@ -1527,8 +1758,7 @@ var ImageEditor = class {
1527
1758
  this._updateMaskList();
1528
1759
  this._updateUI();
1529
1760
  this.canvas.renderAll();
1530
- if (saveHistory)
1531
- this.saveState();
1761
+ if (saveHistory) this.saveState();
1532
1762
  }
1533
1763
  /**
1534
1764
  * Removes the label associated with the specified mask object, if it exists.
@@ -1537,8 +1767,7 @@ var ImageEditor = class {
1537
1767
  * @private
1538
1768
  */
1539
1769
  _removeLabelForMask(mask) {
1540
- if (!mask || !this.canvas)
1541
- return;
1770
+ if (!mask || !this.canvas) return;
1542
1771
  if (mask.__label) {
1543
1772
  try {
1544
1773
  const canvasObjects = this.canvas.getObjects();
@@ -1546,13 +1775,31 @@ var ImageEditor = class {
1546
1775
  this.canvas.remove(mask.__label);
1547
1776
  }
1548
1777
  } catch (error) {
1778
+ void error;
1549
1779
  }
1550
1780
  try {
1551
1781
  delete mask.__label;
1552
1782
  } catch (error) {
1783
+ void error;
1553
1784
  }
1554
1785
  }
1555
1786
  }
1787
+ /**
1788
+ * Returns a stable zero-based creation index for label callbacks.
1789
+ *
1790
+ * Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
1791
+ * lifetime of a mask.
1792
+ *
1793
+ * @param {fabric.Object} mask - Mask object.
1794
+ * @returns {number} Stable zero-based creation index.
1795
+ * @private
1796
+ */
1797
+ _getMaskCreationIndex(mask) {
1798
+ const maskId = Number(mask && mask.maskId);
1799
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
1800
+ const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
1801
+ return Math.max(0, masks.indexOf(mask));
1802
+ }
1556
1803
  /**
1557
1804
  * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1558
1805
  * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
@@ -1561,8 +1808,7 @@ var ImageEditor = class {
1561
1808
  * @private
1562
1809
  */
1563
1810
  _createLabelForMask(mask) {
1564
- if (!mask || !this.options.maskLabelOnSelect)
1565
- return;
1811
+ if (!mask || !this.options.maskLabelOnSelect) return;
1566
1812
  this._removeLabelForMask(mask);
1567
1813
  let textObject = null;
1568
1814
  if (this.options.label && typeof this.options.label.create === "function") {
@@ -1584,9 +1830,7 @@ var ImageEditor = class {
1584
1830
  };
1585
1831
  if (this.options.label) {
1586
1832
  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);
1833
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
1590
1834
  }
1591
1835
  if (this.options.label.textOptions) {
1592
1836
  Object.assign(textOptions, this.options.label.textOptions);
@@ -1606,15 +1850,14 @@ var ImageEditor = class {
1606
1850
  * @private
1607
1851
  */
1608
1852
  _hideAllMaskLabels() {
1609
- if (!this.canvas)
1610
- return;
1853
+ if (!this.canvas) return;
1611
1854
  const canvasObjects = this.canvas.getObjects();
1612
1855
  const labels = canvasObjects.filter((object) => object.maskLabel);
1613
1856
  labels.forEach((label) => {
1614
1857
  try {
1615
- if (canvasObjects.includes(label))
1616
- this.canvas.remove(label);
1858
+ if (canvasObjects.includes(label)) this.canvas.remove(label);
1617
1859
  } catch (error) {
1860
+ void error;
1618
1861
  }
1619
1862
  });
1620
1863
  canvasObjects.forEach((object) => {
@@ -1622,6 +1865,7 @@ var ImageEditor = class {
1622
1865
  try {
1623
1866
  delete object.__label;
1624
1867
  } catch (error) {
1868
+ void error;
1625
1869
  }
1626
1870
  }
1627
1871
  });
@@ -1633,15 +1877,11 @@ var ImageEditor = class {
1633
1877
  * @private
1634
1878
  */
1635
1879
  _syncMaskLabel(mask) {
1636
- if (!mask)
1637
- return;
1638
- if (!this.options.maskLabelOnSelect)
1639
- return;
1640
- if (!mask.__label)
1641
- return;
1880
+ if (!mask) return;
1881
+ if (!this.options.maskLabelOnSelect) return;
1882
+ if (!mask.__label) return;
1642
1883
  const coords = mask.getCoords ? mask.getCoords() : null;
1643
- if (!coords || coords.length < 4)
1644
- return;
1884
+ if (!coords || coords.length < 4) return;
1645
1885
  const tl = coords[0];
1646
1886
  const center = mask.getCenterPoint();
1647
1887
  const vx = center.x - tl.x;
@@ -1674,12 +1914,9 @@ var ImageEditor = class {
1674
1914
  * @private
1675
1915
  */
1676
1916
  _showLabelForMask(mask) {
1677
- if (!mask)
1678
- return;
1679
- if (!this.options.maskLabelOnSelect)
1680
- return;
1681
- if (!mask.__label)
1682
- this._createLabelForMask(mask);
1917
+ if (!mask) return;
1918
+ if (!this.options.maskLabelOnSelect) return;
1919
+ if (!mask.__label) this._createLabelForMask(mask);
1683
1920
  mask.__label.set({ visible: true });
1684
1921
  this._syncMaskLabel(mask);
1685
1922
  }
@@ -1699,6 +1936,7 @@ var ImageEditor = class {
1699
1936
  try {
1700
1937
  this.canvas.remove(mask.__label);
1701
1938
  } catch (error) {
1939
+ void error;
1702
1940
  }
1703
1941
  delete mask.__label;
1704
1942
  }
@@ -1711,8 +1949,7 @@ var ImageEditor = class {
1711
1949
  mask.set({ stroke: "#ff0000", strokeWidth: 1 });
1712
1950
  }
1713
1951
  });
1714
- if (selectedMask)
1715
- this._showLabelForMask(selectedMask);
1952
+ if (selectedMask) this._showLabelForMask(selectedMask);
1716
1953
  this._updateMaskListSelection(selectedMask);
1717
1954
  this.canvas.renderAll();
1718
1955
  this._updateUI();
@@ -1724,8 +1961,7 @@ var ImageEditor = class {
1724
1961
  */
1725
1962
  _updateMaskList() {
1726
1963
  const maskListElement = document.getElementById(this.elements.maskList);
1727
- if (!maskListElement)
1728
- return;
1964
+ if (!maskListElement) return;
1729
1965
  maskListElement.innerHTML = "";
1730
1966
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1731
1967
  masks.forEach((mask) => {
@@ -1747,8 +1983,7 @@ var ImageEditor = class {
1747
1983
  */
1748
1984
  _updateMaskListSelection(selectedMask) {
1749
1985
  const maskListElement = document.getElementById(this.elements.maskList);
1750
- if (!maskListElement)
1751
- return;
1986
+ if (!maskListElement) return;
1752
1987
  const maskItems = maskListElement.querySelectorAll(".mask-item");
1753
1988
  maskItems.forEach((item) => {
1754
1989
  const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
@@ -1756,70 +1991,79 @@ var ImageEditor = class {
1756
1991
  });
1757
1992
  }
1758
1993
  /**
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.
1994
+ * Flattens the current masks into the base image and reloads the flattened image.
1995
+ *
1996
+ * This removes editable mask objects after export and records the operation as one undoable history transition.
1997
+ * It does nothing when no base image or no masks exist.
1998
+ *
1761
1999
  * @async
1762
- * @returns {Promise<void>} Resolves when merge and load are complete.
2000
+ * @returns {Promise<void>} Resolves when the flattened image has been loaded.
2001
+ * @public
1763
2002
  */
1764
2003
  async mergeMasks() {
1765
- if (!this.originalImage)
1766
- return;
2004
+ if (!this.originalImage) return;
1767
2005
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1768
- if (!masks.length)
1769
- return;
2006
+ if (!masks.length) return;
1770
2007
  this.canvas.discardActiveObject();
1771
2008
  this.canvas.renderAll();
1772
2009
  try {
1773
2010
  const beforeJson = this._serializeCanvasState();
1774
2011
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1775
2012
  this.removeAllMasks({ saveHistory: false });
1776
- await this.loadImage(merged);
2013
+ await this.loadImage(merged, { preserveScroll: true });
1777
2014
  const afterJson = this._serializeCanvasState();
1778
2015
  this._pushStateTransition(beforeJson, afterJson);
1779
- } catch (err) {
1780
- this._reportError("merge error", err);
2016
+ } catch (error) {
2017
+ this._reportError("merge error", error);
1781
2018
  }
1782
2019
  }
1783
2020
  /**
1784
- * @deprecated Use mergeMasks() instead.
2021
+ * Backward-compatible alias for {@link ImageEditor#mergeMasks}.
2022
+ *
2023
+ * @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
2024
+ * @returns {Promise<void>} Resolves when mask flattening is complete.
1785
2025
  */
1786
2026
  async merge() {
1787
2027
  return this.mergeMasks();
1788
2028
  }
1789
2029
  /**
1790
- * Triggers a JPEG image download of the current canvas (image plus masks if configured).
2030
+ * Triggers a JPEG image download of the current canvas.
2031
+ *
1791
2032
  * The image area and multiplier are controlled by options.
1792
2033
  * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
2034
+ * @returns {void}
2035
+ * @public
1793
2036
  */
1794
2037
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1795
- if (!this.originalImage)
1796
- return;
2038
+ if (!this.originalImage) return;
1797
2039
  const exportImageArea = this.options.exportImageAreaByDefault;
1798
- this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((base64) => {
2040
+ this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
1799
2041
  const link = document.createElement("a");
1800
2042
  link.download = fileName;
1801
- link.href = base64;
2043
+ link.href = imageBase64;
1802
2044
  document.body.appendChild(link);
1803
2045
  link.click();
1804
2046
  document.body.removeChild(link);
1805
- }).catch((err) => this._reportError("download error", err));
2047
+ }).catch((error) => this._reportError("download error", error));
1806
2048
  }
1807
2049
  /**
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.
2050
+ * Exports the current image as a Base64-encoded data URL.
2051
+ *
2052
+ * When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
2053
+ * temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
2054
+ *
1811
2055
  * @async
1812
2056
  * @param {Object} [options={}] - Export options.
1813
2057
  * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1814
2058
  * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
1815
2059
  * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
1816
2060
  * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
1817
- * @returns {Promise<string>} Promise resolving to an image data URL.
2061
+ * @returns {Promise<string>} Resolves with an image data URL.
1818
2062
  * @throws {Error} If there is no image loaded.
2063
+ * @public
1819
2064
  */
1820
2065
  async exportImageBase64(options = {}) {
1821
- if (!this.originalImage)
1822
- throw new Error("No image loaded");
2066
+ if (!this.originalImage) throw new Error("No image loaded");
1823
2067
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
1824
2068
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
1825
2069
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -1835,12 +2079,9 @@ var ImageEditor = class {
1835
2079
  this.canvas.renderAll();
1836
2080
  this.originalImage.setCoords();
1837
2081
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1838
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2082
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1839
2083
  return await this._exportCanvasRegionToDataURL({
1840
- sx,
1841
- sy,
1842
- sw,
1843
- sh,
2084
+ ...exportRegion,
1844
2085
  multiplier,
1845
2086
  quality,
1846
2087
  format
@@ -1850,6 +2091,7 @@ var ImageEditor = class {
1850
2091
  try {
1851
2092
  backup.object.set({ visible: backup.visible });
1852
2093
  } catch (error) {
2094
+ void error;
1853
2095
  }
1854
2096
  });
1855
2097
  this.canvas.renderAll();
@@ -1877,12 +2119,9 @@ var ImageEditor = class {
1877
2119
  this.canvas.renderAll();
1878
2120
  this.originalImage.setCoords();
1879
2121
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1880
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2122
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1881
2123
  finalBase64 = await this._exportCanvasRegionToDataURL({
1882
- sx,
1883
- sy,
1884
- sw,
1885
- sh,
2124
+ ...exportRegion,
1886
2125
  multiplier,
1887
2126
  quality,
1888
2127
  format
@@ -1900,6 +2139,7 @@ var ImageEditor = class {
1900
2139
  });
1901
2140
  backup.object.setCoords();
1902
2141
  } catch (error) {
2142
+ void error;
1903
2143
  }
1904
2144
  });
1905
2145
  this.canvas.renderAll();
@@ -1907,14 +2147,20 @@ var ImageEditor = class {
1907
2147
  return finalBase64;
1908
2148
  }
1909
2149
  /**
1910
- * @deprecated Use exportImageBase64() instead.
2150
+ * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
2151
+ *
2152
+ * @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
2153
+ * @param {Object} [options={}] - Export options passed to exportImageBase64().
2154
+ * @returns {Promise<string>} Resolves with an image data URL.
1911
2155
  */
1912
2156
  async getImageBase64(options = {}) {
1913
2157
  return this.exportImageBase64(options);
1914
2158
  }
1915
2159
  /**
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).
2160
+ * Exports the current image as a File object.
2161
+ *
2162
+ * The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
2163
+ * Supported output formats are JPEG, PNG, and WebP.
1918
2164
  *
1919
2165
  * @async
1920
2166
  * @param {Object} [options={}] - Export options.
@@ -1929,8 +2175,7 @@ var ImageEditor = class {
1929
2175
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
1930
2176
  */
1931
2177
  async exportImageFile(options = {}) {
1932
- if (!this.originalImage)
1933
- throw new Error("No image loaded");
2178
+ if (!this.originalImage) throw new Error("No image loaded");
1934
2179
  const {
1935
2180
  mergeMask = true,
1936
2181
  fileType = "jpeg",
@@ -1939,23 +2184,23 @@ var ImageEditor = class {
1939
2184
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
1940
2185
  } = options;
1941
2186
  const safeFileType = this._normalizeImageFormat(fileType);
1942
- let base64;
2187
+ let imageBase64;
1943
2188
  if (mergeMask) {
1944
- base64 = await this.exportImageBase64({
2189
+ imageBase64 = await this.exportImageBase64({
1945
2190
  exportImageArea: true,
1946
2191
  multiplier,
1947
2192
  quality,
1948
2193
  fileType: safeFileType
1949
2194
  });
1950
2195
  } else {
1951
- base64 = await this.exportImageBase64({
2196
+ imageBase64 = await this.exportImageBase64({
1952
2197
  exportImageArea: false,
1953
2198
  multiplier,
1954
2199
  quality,
1955
2200
  fileType: safeFileType
1956
2201
  });
1957
2202
  }
1958
- let imageDataUrl = base64;
2203
+ let imageDataUrl = imageBase64;
1959
2204
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1960
2205
  imageDataUrl = await new Promise((resolve, reject) => {
1961
2206
  const imageElement = new window.Image();
@@ -1974,7 +2219,7 @@ var ImageEditor = class {
1974
2219
  }
1975
2220
  };
1976
2221
  imageElement.onerror = reject;
1977
- imageElement.src = base64;
2222
+ imageElement.src = imageBase64;
1978
2223
  });
1979
2224
  }
1980
2225
  const binaryString = atob(imageDataUrl.split(",")[1]);
@@ -1994,8 +2239,7 @@ var ImageEditor = class {
1994
2239
  }
1995
2240
  async _restoreStateAfterCropFailure(beforeJson, message, error) {
1996
2241
  this._reportError(message, error);
1997
- if (this._cropRect && this.canvas)
1998
- this._removeCropRect();
2242
+ if (this._cropRect && this.canvas) this._removeCropRect();
1999
2243
  this._cropRect = null;
2000
2244
  this._cropMode = false;
2001
2245
  if (this.canvas && this._prevSelectionSetting !== void 0) {
@@ -2010,8 +2254,7 @@ var ImageEditor = class {
2010
2254
  }
2011
2255
  }
2012
2256
  this._updateUI();
2013
- if (this.canvas)
2014
- this.canvas.renderAll();
2257
+ if (this.canvas) this.canvas.renderAll();
2015
2258
  }
2016
2259
  _restoreCropObjectState() {
2017
2260
  if (Array.isArray(this._cropPrevEvented)) {
@@ -2023,14 +2266,14 @@ var ImageEditor = class {
2023
2266
  visible: state.visible
2024
2267
  });
2025
2268
  } catch (error) {
2269
+ void error;
2026
2270
  }
2027
2271
  });
2028
2272
  }
2029
2273
  this._cropPrevEvented = null;
2030
2274
  }
2031
2275
  _removeCropRect() {
2032
- if (!this._cropRect)
2033
- return;
2276
+ if (!this._cropRect) return;
2034
2277
  try {
2035
2278
  if (this._cropHandlers && this._cropHandlers.length) {
2036
2279
  this._cropHandlers.forEach((targetHandlers) => {
@@ -2040,23 +2283,28 @@ var ImageEditor = class {
2040
2283
  });
2041
2284
  }
2042
2285
  } catch (error) {
2286
+ void error;
2043
2287
  }
2044
2288
  try {
2045
2289
  this.canvas.remove(this._cropRect);
2046
2290
  } catch (error) {
2291
+ void error;
2047
2292
  }
2048
2293
  this._cropRect = null;
2049
2294
  this._cropHandlers = [];
2050
2295
  }
2051
2296
  /**
2052
- * Enter crop mode: create a resizable/movable selection rect on top of the image.
2297
+ * Enters crop mode by creating a resizable crop rectangle above the base image.
2298
+ *
2299
+ * Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
2300
+ * cropping when `crop.hideMasksDuringCrop` is enabled.
2301
+ *
2302
+ * @returns {void}
2053
2303
  * @public
2054
2304
  */
2055
2305
  enterCropMode() {
2056
- if (!this.canvas || !this.originalImage || this._cropMode)
2057
- return;
2058
- if (!this.isImageLoaded())
2059
- return;
2306
+ if (!this.canvas || !this.originalImage || this._cropMode) return;
2307
+ if (!this.isImageLoaded()) return;
2060
2308
  this._cropMode = true;
2061
2309
  this._prevSelectionSetting = this.canvas.selection;
2062
2310
  this.canvas.selection = false;
@@ -2066,8 +2314,14 @@ var ImageEditor = class {
2066
2314
  const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
2067
2315
  const left = Math.max(0, Math.floor(imageBounds.left + padding));
2068
2316
  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));
2317
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2318
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
2319
+ const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
2320
+ const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
2321
+ const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
2322
+ const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
2323
+ const width = minCropWidth;
2324
+ const height = minCropHeight;
2071
2325
  const cropRect = new fabric.Rect({
2072
2326
  left,
2073
2327
  top,
@@ -2084,7 +2338,8 @@ var ImageEditor = class {
2084
2338
  cornerSize: 8,
2085
2339
  objectCaching: false,
2086
2340
  originX: "left",
2087
- originY: "top"
2341
+ originY: "top",
2342
+ lockScalingFlip: true
2088
2343
  });
2089
2344
  this.canvas.add(cropRect);
2090
2345
  cropRect.isCropRect = true;
@@ -2101,18 +2356,24 @@ var ImageEditor = class {
2101
2356
  evented: false,
2102
2357
  selectable: false
2103
2358
  };
2104
- if (shouldHideMasks && (object.maskId || object.maskLabel))
2105
- updates.visible = false;
2359
+ if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
2106
2360
  object.set(updates);
2107
2361
  } catch (error) {
2362
+ void error;
2108
2363
  }
2109
2364
  }
2110
2365
  });
2111
2366
  const handleCropRectModified = () => {
2112
2367
  try {
2368
+ const cropWidth = Math.max(1, Number(cropRect.width) || 1);
2369
+ const cropHeight = Math.max(1, Number(cropRect.height) || 1);
2370
+ const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
2371
+ const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2372
+ cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2113
2373
  cropRect.setCoords();
2114
2374
  this.canvas.requestRenderAll();
2115
2375
  } catch (error) {
2376
+ void error;
2116
2377
  }
2117
2378
  };
2118
2379
  cropRect.on("modified", handleCropRectModified);
@@ -2130,12 +2391,13 @@ var ImageEditor = class {
2130
2391
  this.canvas.renderAll();
2131
2392
  }
2132
2393
  /**
2133
- * Cancel crop mode and remove the temporary selection rect.
2394
+ * Cancels crop mode and removes the temporary crop rectangle.
2395
+ *
2396
+ * @returns {void}
2134
2397
  * @public
2135
2398
  */
2136
2399
  cancelCrop() {
2137
- if (!this.canvas || !this._cropMode)
2138
- return;
2400
+ if (!this.canvas || !this._cropMode) return;
2139
2401
  this._removeCropRect();
2140
2402
  this._restoreCropObjectState();
2141
2403
  this._cropMode = false;
@@ -2146,19 +2408,24 @@ var ImageEditor = class {
2146
2408
  this.canvas.renderAll();
2147
2409
  }
2148
2410
  /**
2149
- * Apply the current crop rectangle.
2150
- * remove all masks and export canvas snapshot and crop via offscreen canvas
2411
+ * Applies the current crop rectangle to the base image.
2412
+ *
2413
+ * Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
2414
+ * region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
2415
+ * single undoable history transition.
2416
+ *
2417
+ * @async
2418
+ * @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
2151
2419
  * @public
2152
2420
  */
2153
2421
  async applyCrop() {
2154
- if (!this.canvas || !this._cropMode || !this._cropRect)
2155
- return;
2422
+ if (!this.canvas || !this._cropMode || !this._cropRect) return;
2156
2423
  this._cropRect.setCoords();
2157
2424
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2158
- const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
2425
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
2159
2426
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2160
2427
  this._restoreCropObjectState();
2161
- let beforeJson = null;
2428
+ let beforeJson;
2162
2429
  try {
2163
2430
  beforeJson = this._serializeCanvasState();
2164
2431
  } catch (error) {
@@ -2173,13 +2440,13 @@ var ImageEditor = class {
2173
2440
  try {
2174
2441
  mask.setCoords();
2175
2442
  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;
2443
+ 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
2444
  this._removeLabelForMask(mask);
2178
2445
  this.canvas.remove(mask);
2179
2446
  if (shouldPreserveMasks && intersectsCrop) {
2180
2447
  mask.set({
2181
- left: (mask.left || 0) - sx,
2182
- top: (mask.top || 0) - sy,
2448
+ left: (mask.left || 0) - cropRegion.sourceX,
2449
+ top: (mask.top || 0) - cropRegion.sourceY,
2183
2450
  visible: true
2184
2451
  });
2185
2452
  mask.setCoords();
@@ -2203,10 +2470,7 @@ var ImageEditor = class {
2203
2470
  let croppedBase64;
2204
2471
  try {
2205
2472
  croppedBase64 = await this._exportCanvasRegionToDataURL({
2206
- sx,
2207
- sy,
2208
- sw,
2209
- sh,
2473
+ ...cropRegion,
2210
2474
  multiplier: 1,
2211
2475
  quality: this._normalizeQuality(this.options.downsampleQuality),
2212
2476
  format: "jpeg"
@@ -2228,21 +2492,21 @@ var ImageEditor = class {
2228
2492
  this._updateMaskList();
2229
2493
  this.canvas.renderAll();
2230
2494
  }
2231
- } catch (e) {
2232
- await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", e);
2495
+ } catch (error) {
2496
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
2233
2497
  return;
2234
2498
  }
2235
- let afterJson = null;
2499
+ let afterJson;
2236
2500
  try {
2237
2501
  afterJson = this._serializeCanvasState();
2238
- } catch (e) {
2239
- this._reportWarning("applyCrop: failed to serialize after state", e);
2502
+ } catch (error) {
2503
+ this._reportWarning("applyCrop: failed to serialize after state", error);
2240
2504
  afterJson = null;
2241
2505
  }
2242
2506
  try {
2243
2507
  this._pushStateTransition(beforeJson, afterJson);
2244
- } catch (e) {
2245
- this._reportWarning("applyCrop: failed to push history command", e);
2508
+ } catch (error) {
2509
+ this._reportWarning("applyCrop: failed to push history command", error);
2246
2510
  }
2247
2511
  this._updateUI();
2248
2512
  this.canvas.renderAll();
@@ -2255,8 +2519,7 @@ var ImageEditor = class {
2255
2519
  */
2256
2520
  _updateInputs() {
2257
2521
  const scaleInputElement = document.getElementById(this.elements.scaleRate);
2258
- if (scaleInputElement)
2259
- scaleInputElement.value = Math.round(this.currentScale * 100);
2522
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2260
2523
  }
2261
2524
  /**
2262
2525
  * Updates the enabled/disabled state of various UI controls (buttons)
@@ -2276,8 +2539,7 @@ var ImageEditor = class {
2276
2539
  if (isInCropMode) {
2277
2540
  for (const key of Object.keys(this.elements || {})) {
2278
2541
  const element = document.getElementById(this.elements[key]);
2279
- if (!element)
2280
- continue;
2542
+ if (!element) continue;
2281
2543
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2282
2544
  this._setDisabled(key, false);
2283
2545
  } else {
@@ -2313,8 +2575,7 @@ var ImageEditor = class {
2313
2575
  */
2314
2576
  _setDisabled(key, disabled) {
2315
2577
  const element = document.getElementById(this.elements[key]);
2316
- if (!element)
2317
- return;
2578
+ if (!element) return;
2318
2579
  if ("disabled" in element) {
2319
2580
  element.disabled = !!disabled;
2320
2581
  return;
@@ -2328,38 +2589,42 @@ var ImageEditor = class {
2328
2589
  }
2329
2590
  }
2330
2591
  _isElementDisabled(element) {
2331
- if (!element)
2332
- return false;
2333
- if ("disabled" in element)
2334
- return !!element.disabled;
2592
+ if (!element) return false;
2593
+ if ("disabled" in element) return !!element.disabled;
2335
2594
  return element.getAttribute("aria-disabled") === "true";
2336
2595
  }
2337
2596
  /**
2338
- * Automatically display and hide placeholders and containers based on the current image content
2597
+ * Updates placeholder and canvas container visibility based on whether an image is loaded.
2339
2598
  * @private
2340
2599
  */
2341
2600
  _updatePlaceholderStatus() {
2342
- if (!this.options.showPlaceholder)
2343
- return;
2601
+ if (!this.options.showPlaceholder) return;
2344
2602
  this._setPlaceholderVisible(!this.originalImage);
2345
2603
  }
2346
2604
  /**
2347
- * Controls the display/hiding of the Placeholder and Canvas container.
2348
- * @param {boolean} show - true displays the placeholder, false displays the canvas container
2605
+ * Shows or hides the placeholder and canvas container.
2606
+ *
2607
+ * @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
2349
2608
  * @private
2350
2609
  */
2351
2610
  _setPlaceholderVisible(show) {
2352
- if (!this.placeholderElement)
2353
- return;
2354
- if (show) {
2355
- this.placeholderElement.classList.remove("d-none");
2356
- this.placeholderElement.classList.add("d-flex");
2357
- this.containerElement.classList.add("d-none");
2358
- } else {
2359
- this.placeholderElement.classList.remove("d-flex");
2360
- this.placeholderElement.classList.add("d-none");
2361
- this.containerElement.classList.remove("d-none");
2362
- }
2611
+ if (!this.placeholderElement || !this.containerElement) return;
2612
+ this._setElementVisible(this.placeholderElement, show);
2613
+ this._setElementVisible(this.containerElement, !show);
2614
+ }
2615
+ /**
2616
+ * Updates element visibility.
2617
+ *
2618
+ * @param {HTMLElement} element - Element whose visibility should be updated.
2619
+ * @param {boolean} isVisible - If true, removes the hidden state.
2620
+ * @returns {void}
2621
+ * @private
2622
+ */
2623
+ _setElementVisible(element, isVisible) {
2624
+ if (!element) return;
2625
+ element.hidden = !isVisible;
2626
+ element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2627
+ if (isVisible && element.classList) element.classList.remove("d-none");
2363
2628
  }
2364
2629
  /**
2365
2630
  * Cleans up and disposes of the canvas and related references.
@@ -2371,34 +2636,38 @@ var ImageEditor = class {
2371
2636
  for (const key in this._handlersByElementKey || {}) {
2372
2637
  const handlers = this._handlersByElementKey[key] || [];
2373
2638
  const element = document.getElementById(this.elements[key]);
2374
- if (!element)
2375
- continue;
2639
+ if (!element) continue;
2376
2640
  handlers.forEach((handlerRecord) => {
2377
2641
  try {
2378
2642
  element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
2379
2643
  } catch (error) {
2644
+ void error;
2380
2645
  }
2381
2646
  });
2382
2647
  }
2383
2648
  } catch (error) {
2649
+ void error;
2384
2650
  }
2385
2651
  if (this._cropRect) {
2386
2652
  try {
2387
2653
  this.canvas.remove(this._cropRect);
2388
- } catch (e) {
2654
+ } catch (error) {
2655
+ void error;
2389
2656
  }
2390
2657
  this._cropRect = null;
2391
2658
  }
2392
2659
  if (this.containerElement && this._containerOriginalOverflow !== void 0) {
2393
2660
  try {
2394
2661
  this.containerElement.style.overflow = this._containerOriginalOverflow;
2395
- } catch (e) {
2662
+ } catch (error) {
2663
+ void error;
2396
2664
  }
2397
2665
  }
2398
2666
  if (this.canvas) {
2399
2667
  try {
2400
2668
  this.canvas.dispose();
2401
- } catch (e) {
2669
+ } catch (error) {
2670
+ void error;
2402
2671
  }
2403
2672
  this.canvas = null;
2404
2673
  this.canvasElement = null;
@@ -2409,54 +2678,52 @@ var ImageEditor = class {
2409
2678
  };
2410
2679
  var AnimationQueue = class {
2411
2680
  /**
2412
- * Creates a new AnimationQueue.
2413
- *
2414
- * @constructor
2681
+ * Creates an empty animation queue.
2415
2682
  */
2416
2683
  constructor() {
2417
- this.queue = [];
2418
- this.running = false;
2684
+ this.animationTasks = [];
2685
+ this.isRunning = false;
2419
2686
  }
2420
2687
  /**
2421
2688
  * Adds an animation function to the queue.
2422
2689
  *
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.
2690
+ * @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
2691
+ * @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
2425
2692
  */
2426
2693
  async add(animationFn) {
2427
2694
  return new Promise((resolve, reject) => {
2428
- this.queue.push({ fn: animationFn, resolve, reject });
2429
- if (!this.running) {
2430
- this.processQueue();
2695
+ this.animationTasks.push({ animationFn, resolve, reject });
2696
+ if (!this.isRunning) {
2697
+ this._drainQueue();
2431
2698
  }
2432
2699
  });
2433
2700
  }
2434
2701
  /**
2435
- * Internal helper that processes the animation queue sequentially until it is empty.
2702
+ * Runs queued animation tasks sequentially until the queue is empty.
2436
2703
  *
2437
2704
  * @private
2438
2705
  * @returns {Promise<void>}
2439
2706
  */
2440
- async processQueue() {
2441
- if (this.queue.length === 0) {
2442
- this.running = false;
2707
+ async _drainQueue() {
2708
+ if (this.animationTasks.length === 0) {
2709
+ this.isRunning = false;
2443
2710
  return;
2444
2711
  }
2445
- this.running = true;
2446
- const { fn, resolve, reject } = this.queue.shift();
2712
+ this.isRunning = true;
2713
+ const { animationFn, resolve, reject } = this.animationTasks.shift();
2447
2714
  try {
2448
- const result = await fn();
2715
+ const result = await animationFn();
2449
2716
  resolve(result);
2450
2717
  } catch (error) {
2451
2718
  reject(error);
2452
2719
  }
2453
- this.processQueue();
2720
+ await this._drainQueue();
2454
2721
  }
2455
2722
  };
2456
2723
  var Command = class {
2457
2724
  /**
2458
- * @param {Function} execute The function that performs the action.
2459
- * @param {Function} undo The function that reverts the action.
2725
+ * @param {HistoryTaskCallback} execute - Function that performs the action.
2726
+ * @param {HistoryTaskCallback} undo - Function that reverts the action.
2460
2727
  */
2461
2728
  constructor(execute, undo) {
2462
2729
  this.execute = execute;
@@ -2465,7 +2732,7 @@ var Command = class {
2465
2732
  };
2466
2733
  var HistoryManager = class {
2467
2734
  /**
2468
- * @param {number} [maxSize=50] Maximum number of commands to keep in history.
2735
+ * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
2469
2736
  */
2470
2737
  constructor(maxSize = 50) {
2471
2738
  this.history = [];
@@ -2473,11 +2740,24 @@ var HistoryManager = class {
2473
2740
  this.maxSize = maxSize;
2474
2741
  this.pending = Promise.resolve();
2475
2742
  }
2743
+ /**
2744
+ * Queues a history task after the previously queued undo/redo task completes.
2745
+ *
2746
+ * @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
2747
+ * @returns {Promise<void>} Resolves or rejects with the queued task result.
2748
+ * @private
2749
+ */
2476
2750
  enqueue(task) {
2477
- const run = this.pending.then(task, task);
2478
- this.pending = run.catch(() => {
2479
- });
2480
- return run;
2751
+ const nextTask = this.pending.then(task, task);
2752
+ let pendingAfterTask;
2753
+ const resetPending = () => {
2754
+ if (this.pending === pendingAfterTask) {
2755
+ this.pending = Promise.resolve();
2756
+ }
2757
+ };
2758
+ pendingAfterTask = nextTask.then(resetPending, resetPending);
2759
+ this.pending = pendingAfterTask;
2760
+ return nextTask;
2481
2761
  }
2482
2762
  /**
2483
2763
  * Executes a new command and pushes it onto the history stack.
@@ -2527,7 +2807,7 @@ var HistoryManager = class {
2527
2807
  /**
2528
2808
  * Undoes the last executed command if possible.
2529
2809
  *
2530
- * @returns {void}
2810
+ * @returns {Promise<void>} Resolves after the undo task completes.
2531
2811
  */
2532
2812
  undo() {
2533
2813
  return this.enqueue(async () => {
@@ -2541,7 +2821,7 @@ var HistoryManager = class {
2541
2821
  /**
2542
2822
  * Redoes the next command in history if possible.
2543
2823
  *
2544
- * @returns {void}
2824
+ * @returns {Promise<void>} Resolves after the redo task completes.
2545
2825
  */
2546
2826
  redo() {
2547
2827
  return this.enqueue(async () => {