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