@bensitu/image-editor 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,16 @@
3
3
  /**
4
4
  * @file image-editor.js
5
5
  * @module image-editor
6
- * @version 1.3.0
6
+ * @version 1.4.0
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,8 @@
74
70
  downsampleMaxWidth: 4e3,
75
71
  downsampleMaxHeight: 3e3,
76
72
  downsampleQuality: 0.92,
73
+ preserveSourceFormat: true,
74
+ downsampleMimeType: null,
77
75
  imageLoadTimeoutMs: 3e4,
78
76
  exportMultiplier: 1,
79
77
  exportImageAreaByDefault: true,
@@ -122,6 +120,7 @@
122
120
  this.isImageLoadedToCanvas = false;
123
121
  this.maxHistorySize = 50;
124
122
  this._handlersByElementKey = {};
123
+ this._elementCache = {};
125
124
  this._lastMask = null;
126
125
  this._lastMaskInitialLeft = null;
127
126
  this._lastMaskInitialTop = null;
@@ -132,8 +131,14 @@
132
131
  this._cropHandlers = [];
133
132
  this._cropPrevEvented = null;
134
133
  this._prevSelectionSetting = void 0;
135
- this._containerOriginalOverflow = void 0;
134
+ this._containerOriginalOverflow = null;
135
+ this._lastContainerViewportSize = null;
136
+ this._canvasElementOriginalStyle = null;
137
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
136
138
  this._scrollbarSizeCache = null;
139
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
140
+ this._disposed = false;
141
+ this._initialized = false;
137
142
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
138
143
  this.animationQueue = new AnimationQueue();
139
144
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -196,8 +201,17 @@
196
201
  * });
197
202
  */
198
203
  init(idMap = {}) {
199
- if (!this._fabricLoaded)
200
- return;
204
+ if (!this._fabricLoaded) return;
205
+ if (this._initialized || this.canvas) this.dispose();
206
+ this._disposed = false;
207
+ this._initialized = true;
208
+ this.animationQueue = new AnimationQueue();
209
+ this.historyManager = new HistoryManager(this.maxHistorySize);
210
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
211
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
212
+ this._containerOriginalOverflow = null;
213
+ this._lastContainerViewportSize = null;
214
+ this._canvasElementOriginalStyle = null;
201
215
  const defaults = {
202
216
  canvas: "fabricCanvas",
203
217
  canvasContainer: null,
@@ -225,6 +239,7 @@
225
239
  cancelCropBtn: "cancelCropBtn"
226
240
  };
227
241
  this.elements = { ...defaults, ...idMap };
242
+ this._elementCache = {};
228
243
  this._initCanvas();
229
244
  this._bindEvents();
230
245
  this._updateInputs();
@@ -238,8 +253,7 @@
238
253
  }
239
254
  _reportError(message, error = null) {
240
255
  const handler = this.options && this.options.onError;
241
- if (typeof handler !== "function")
242
- return;
256
+ if (typeof handler !== "function") return;
243
257
  try {
244
258
  handler(error, message);
245
259
  } catch {
@@ -247,8 +261,7 @@
247
261
  }
248
262
  _reportWarning(message, error = null) {
249
263
  const handler = this.options && this.options.onWarning;
250
- if (typeof handler !== "function")
251
- return;
264
+ if (typeof handler !== "function") return;
252
265
  try {
253
266
  handler(error, message);
254
267
  } catch {
@@ -261,17 +274,22 @@
261
274
  * @private
262
275
  */
263
276
  _initCanvas() {
264
- const canvasElement = document.getElementById(this.elements.canvas);
265
- if (!canvasElement)
266
- throw new Error("Canvas is not found: " + this.elements.canvas);
277
+ const canvasElement = this._getElement("canvas");
278
+ if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
267
279
  this.canvasElement = canvasElement;
280
+ this._canvasElementOriginalStyle = {
281
+ display: canvasElement.style.display || "",
282
+ width: canvasElement.style.width || "",
283
+ height: canvasElement.style.height || "",
284
+ maxWidth: canvasElement.style.maxWidth || ""
285
+ };
268
286
  if (this.elements.canvasContainer) {
269
- const containerElement = document.getElementById(this.elements.canvasContainer);
287
+ const containerElement = this._getElement("canvasContainer");
270
288
  this.containerElement = containerElement || canvasElement.parentElement;
271
289
  } else {
272
290
  this.containerElement = canvasElement.parentElement;
273
291
  }
274
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
292
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
275
293
  let initialWidth = this.options.canvasWidth;
276
294
  let initialHeight = this.options.canvasHeight;
277
295
  if (this.containerElement) {
@@ -280,6 +298,10 @@
280
298
  if (containerWidth > 0 && containerHeight > 0) {
281
299
  initialWidth = containerWidth;
282
300
  initialHeight = containerHeight;
301
+ this._lastContainerViewportSize = {
302
+ width: containerWidth,
303
+ height: containerHeight
304
+ };
283
305
  }
284
306
  }
285
307
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -293,20 +315,34 @@
293
315
  this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
294
316
  this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
295
317
  this.canvas.on("object:moving", (event) => {
296
- if (event.target && event.target.maskId)
297
- this._syncMaskLabel(event.target);
318
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
298
319
  });
299
320
  this.canvas.on("object:scaling", (event) => {
300
- if (event.target && event.target.maskId)
301
- this._syncMaskLabel(event.target);
321
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
302
322
  });
303
323
  this.canvas.on("object:rotating", (event) => {
304
- if (event.target && event.target.maskId)
305
- this._syncMaskLabel(event.target);
324
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
306
325
  });
307
326
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
308
327
  this.canvasElement.style.display = "block";
309
328
  }
329
+ /**
330
+ * Returns a configured DOM element and caches lookups for hot UI paths.
331
+ *
332
+ * @param {string} key - Key in the configured element map.
333
+ * @returns {HTMLElement|null} The configured element, or null when missing.
334
+ * @private
335
+ */
336
+ _getElement(key) {
337
+ const id = this.elements && this.elements[key];
338
+ if (!id) return null;
339
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
340
+ return this._elementCache[key];
341
+ }
342
+ const element = document.getElementById(id);
343
+ if (this._elementCache) this._elementCache[key] = element || null;
344
+ return element || null;
345
+ }
310
346
  /**
311
347
  * Records a history entry after Fabric finishes modifying one or more masks.
312
348
  *
@@ -316,11 +352,9 @@
316
352
  */
317
353
  _handleObjectModified(target) {
318
354
  const masks = this._getModifiedMasks(target);
319
- if (!masks.length)
320
- return;
355
+ if (!masks.length) return;
321
356
  masks.forEach((mask) => {
322
- if (typeof mask.setCoords === "function")
323
- mask.setCoords();
357
+ if (typeof mask.setCoords === "function") mask.setCoords();
324
358
  this._syncMaskLabel(mask);
325
359
  });
326
360
  this._expandCanvasToFitObjects(masks);
@@ -334,10 +368,8 @@
334
368
  * @private
335
369
  */
336
370
  _getModifiedMasks(target) {
337
- if (!target)
338
- return [];
339
- if (target.maskId)
340
- return [target];
371
+ if (!target) return [];
372
+ if (target.maskId) return [target];
341
373
  const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
342
374
  return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
343
375
  }
@@ -350,11 +382,8 @@
350
382
  * @private
351
383
  */
352
384
  _syncContainerOverflow(options = {}) {
353
- if (!this.containerElement || !this.containerElement.style)
354
- return;
355
- if (this._containerOriginalOverflow === void 0) {
356
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
357
- }
385
+ if (!this.containerElement || !this.containerElement.style) return;
386
+ this._captureContainerOverflowState();
358
387
  const shouldPreserveScroll = options.preserveScroll === true;
359
388
  if (this.options.coverImageToCanvas) {
360
389
  this.containerElement.style.overflow = "scroll";
@@ -369,62 +398,77 @@
369
398
  this.containerElement.scrollTop = 0;
370
399
  }
371
400
  } else {
372
- this.containerElement.style.overflow = this._containerOriginalOverflow;
401
+ this._restoreContainerOverflowState();
373
402
  }
374
403
  }
404
+ _captureContainerOverflowState() {
405
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
406
+ this._containerOriginalOverflow = {
407
+ overflow: this.containerElement.style.overflow || "",
408
+ overflowX: this.containerElement.style.overflowX || "",
409
+ overflowY: this.containerElement.style.overflowY || ""
410
+ };
411
+ }
412
+ _restoreContainerOverflowState() {
413
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
414
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
415
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
416
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
417
+ }
375
418
  /**
376
419
  * DOM / UI bindings
377
420
  * @private
378
421
  */
379
422
  _bindEvents() {
380
423
  this._bindIfExists("uploadArea", "click", () => {
381
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
382
- if (this._isElementDisabled(uploadAreaElement))
383
- return;
384
- document.getElementById(this.elements.imageInput)?.click();
424
+ const uploadAreaElement = this._getElement("uploadArea");
425
+ if (this._isElementDisabled(uploadAreaElement)) return;
426
+ this._getElement("imageInput")?.click();
385
427
  });
386
428
  this._bindIfExists("imageInput", "change", (event) => {
387
429
  const file = event.target.files && event.target.files[0];
388
- if (file)
389
- this._loadImageFile(file);
430
+ if (file) {
431
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
432
+ event.target.value = "";
433
+ });
434
+ }
390
435
  });
391
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
392
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
436
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
437
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
393
438
  this._bindIfExists("resetBtn", "click", () => {
394
- this.resetImageTransform();
439
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
395
440
  });
396
441
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
397
442
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
398
443
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
399
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
444
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
400
445
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
401
- this._bindIfExists("undoBtn", "click", () => this.undo());
402
- this._bindIfExists("redoBtn", "click", () => this.redo());
446
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
447
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
403
448
  this._bindIfExists("rotateLeftBtn", "click", () => {
404
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
449
+ const rotationInputElement = this._getElement("rotationLeftInput");
405
450
  let step = this.options.rotationStep;
406
451
  if (rotationInputElement) {
407
452
  const parsedStep = parseFloat(rotationInputElement.value);
408
- if (!isNaN(parsedStep))
409
- step = parsedStep;
453
+ if (!isNaN(parsedStep)) step = parsedStep;
410
454
  }
411
- this.rotateImage(this.currentRotation - step);
455
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
412
456
  });
413
457
  this._bindIfExists("rotateRightBtn", "click", () => {
414
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
458
+ const rotationInputElement = this._getElement("rotationRightInput");
415
459
  let step = this.options.rotationStep;
416
460
  if (rotationInputElement) {
417
461
  const parsedStep = parseFloat(rotationInputElement.value);
418
- if (!isNaN(parsedStep))
419
- step = parsedStep;
462
+ if (!isNaN(parsedStep)) step = parsedStep;
420
463
  }
421
- this.rotateImage(this.currentRotation + step);
464
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
422
465
  });
423
466
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
424
467
  this._bindIfExists("applyCropBtn", "click", () => {
425
468
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
426
469
  });
427
470
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
471
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
428
472
  }
429
473
  /**
430
474
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -435,12 +479,11 @@
435
479
  * @private
436
480
  */
437
481
  _bindIfExists(key, eventName, handler) {
438
- const element = document.getElementById(this.elements[key]);
482
+ const element = this._getElement(key);
439
483
  if (element) {
440
484
  element.addEventListener(eventName, handler);
441
485
  this._handlersByElementKey = this._handlersByElementKey || {};
442
- if (!this._handlersByElementKey[key])
443
- this._handlersByElementKey[key] = [];
486
+ if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
444
487
  this._handlersByElementKey[key].push({ eventName, handler });
445
488
  }
446
489
  }
@@ -448,17 +491,33 @@
448
491
  * Reads an image File as a data URL and loads it into the Fabric canvas.
449
492
  *
450
493
  * @param {File} file - Image file selected by the user.
494
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
451
495
  * @private
452
496
  */
453
497
  _loadImageFile(file) {
454
- if (!file || !file.type.startsWith("image/"))
455
- return;
456
- const reader = new FileReader();
457
- reader.onload = (event) => this.loadImage(event.target.result);
458
- reader.onerror = (event) => {
459
- this._reportError("Image file could not be read", event);
460
- };
461
- reader.readAsDataURL(file);
498
+ if (!this._isSupportedImageFile(file)) {
499
+ const error = new Error("Selected file is not a supported image");
500
+ this._reportError("Selected file is not a supported image", error);
501
+ return Promise.reject(error);
502
+ }
503
+ return new Promise((resolve, reject) => {
504
+ const reader = new FileReader();
505
+ reader.onload = (event) => {
506
+ this.loadImage(event.target.result).then(resolve).catch(reject);
507
+ };
508
+ reader.onerror = (event) => {
509
+ const error = new Error("Image file could not be read");
510
+ this._reportError("Image file could not be read", event);
511
+ reject(error);
512
+ };
513
+ reader.readAsDataURL(file);
514
+ });
515
+ }
516
+ _isSupportedImageFile(file) {
517
+ if (!file) return false;
518
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
519
+ const fileName = String(file.name || "");
520
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
462
521
  }
463
522
  /**
464
523
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -472,8 +531,7 @@
472
531
  ["coverImageToCanvas", this.options.coverImageToCanvas],
473
532
  ["expandCanvasToImage", this.options.expandCanvasToImage]
474
533
  ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
475
- if (activeModes.length <= 1)
476
- return;
534
+ if (activeModes.length <= 1) return;
477
535
  this._reportWarning(
478
536
  `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
479
537
  );
@@ -488,103 +546,98 @@
488
546
  * @public
489
547
  */
490
548
  async loadImage(imageBase64, options = {}) {
491
- if (!this._fabricLoaded)
492
- return;
493
- if (!this.canvas)
494
- return;
495
- if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
496
- return;
549
+ if (!this._fabricLoaded) return;
550
+ if (!this.canvas || this._disposed) return;
551
+ if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
552
+ this._assertIdleForOperation("loadImage");
497
553
  this._warnOnImageLayoutOptionConflict();
498
- this._setPlaceholderVisible(false);
499
- this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
500
- const imageElement = await this._createImageElement(imageBase64);
501
- let loadSource = imageBase64;
502
- if (this.options.downsampleOnLoad) {
503
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
504
- if (shouldResize) {
505
- const ratio = Math.min(
506
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
507
- this.options.downsampleMaxHeight / imageElement.naturalHeight
508
- );
509
- const targetWidth = Math.round(imageElement.naturalWidth * ratio);
510
- const targetHeight = Math.round(imageElement.naturalHeight * ratio);
511
- loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
554
+ const transaction = this._captureLoadImageTransaction();
555
+ try {
556
+ const imageElement = await this._createImageElement(imageBase64);
557
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
558
+ let loadSource = imageBase64;
559
+ if (this.options.downsampleOnLoad) {
560
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
561
+ if (shouldResize) {
562
+ const ratio = Math.min(
563
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
564
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
565
+ );
566
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
567
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
568
+ loadSource = this._resampleImageToDataURL(
569
+ imageElement,
570
+ targetWidth,
571
+ targetHeight,
572
+ this.options.downsampleQuality,
573
+ imageBase64
574
+ );
575
+ }
512
576
  }
577
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
578
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
579
+ this.canvas.discardActiveObject();
580
+ this._hideAllMaskLabels();
581
+ this.canvas.clear();
582
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
583
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
584
+ this._setPlaceholderVisible(false);
585
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
586
+ const imageWidth = fabricImage.width;
587
+ const imageHeight = fabricImage.height;
588
+ const viewport = this._getContainerViewportSize();
589
+ const minWidth = viewport.width;
590
+ const minHeight = viewport.height;
591
+ if (this.options.fitImageToCanvas) {
592
+ const canvasWidth = Math.max(1, minWidth - 1);
593
+ const canvasHeight = Math.max(1, minHeight - 1);
594
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
595
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
596
+ fabricImage.set({ left: 0, top: 0 });
597
+ fabricImage.scale(fitScale);
598
+ this.baseImageScale = fabricImage.scaleX || 1;
599
+ } else if (this.options.coverImageToCanvas) {
600
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
601
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
602
+ fabricImage.set({ left: 0, top: 0 });
603
+ fabricImage.scale(layout.scale);
604
+ this.baseImageScale = fabricImage.scaleX || 1;
605
+ } else if (this.options.expandCanvasToImage) {
606
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
607
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
608
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
609
+ fabricImage.set({ left: 0, top: 0 });
610
+ fabricImage.scale(1);
611
+ this.baseImageScale = 1;
612
+ } else {
613
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
614
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
615
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
616
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
617
+ fabricImage.set({ left: 0, top: 0 });
618
+ fabricImage.scale(fitScale);
619
+ this.baseImageScale = fabricImage.scaleX || 1;
620
+ }
621
+ this.originalImage = fabricImage;
622
+ this.canvas.add(fabricImage);
623
+ this.canvas.sendToBack(fabricImage);
624
+ this._clearMaskPlacementMemory();
625
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
626
+ this.currentScale = 1;
627
+ this.currentRotation = 0;
628
+ this._updateInputs();
629
+ this._updateMaskList();
630
+ this.isImageLoadedToCanvas = true;
631
+ this._updateUI();
632
+ this.canvas.renderAll();
633
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
634
+ if (typeof this.onImageLoaded === "function") {
635
+ this.onImageLoaded();
636
+ }
637
+ } catch (error) {
638
+ await this._rollbackLoadImageTransaction(transaction);
639
+ throw error;
513
640
  }
514
- return new Promise((resolve, reject) => {
515
- fabric.Image.fromURL(loadSource, (fabricImage) => {
516
- try {
517
- if (!fabricImage)
518
- throw new Error("Image could not be loaded");
519
- this.canvas.discardActiveObject();
520
- this._hideAllMaskLabels();
521
- this.canvas.clear();
522
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
523
- fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
524
- const imageWidth = fabricImage.width;
525
- const imageHeight = fabricImage.height;
526
- const viewport = this._getContainerViewportSize();
527
- const minWidth = viewport.width;
528
- const minHeight = viewport.height;
529
- if (this.options.fitImageToCanvas) {
530
- const canvasWidth = Math.max(1, minWidth - 1);
531
- const canvasHeight = Math.max(1, minHeight - 1);
532
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
533
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
534
- fabricImage.set({ left: 0, top: 0 });
535
- fabricImage.scale(fitScale);
536
- this.baseImageScale = fabricImage.scaleX || 1;
537
- } else if (this.options.coverImageToCanvas) {
538
- const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
539
- this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
540
- fabricImage.set({ left: 0, top: 0 });
541
- fabricImage.scale(layout.scale);
542
- this.baseImageScale = fabricImage.scaleX || 1;
543
- } else if (this.options.expandCanvasToImage) {
544
- const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
545
- const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
546
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
547
- fabricImage.set({ left: 0, top: 0 });
548
- fabricImage.scale(1);
549
- this.baseImageScale = 1;
550
- } else {
551
- const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
552
- const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
553
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
554
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
555
- fabricImage.set({ left: 0, top: 0 });
556
- fabricImage.scale(fitScale);
557
- this.baseImageScale = fabricImage.scaleX || 1;
558
- }
559
- this.originalImage = fabricImage;
560
- this.canvas.add(fabricImage);
561
- this.canvas.sendToBack(fabricImage);
562
- this._lastMask = null;
563
- this._lastMaskInitialLeft = null;
564
- this._lastMaskInitialTop = null;
565
- this._lastMaskInitialWidth = null;
566
- this.maskCounter = 0;
567
- this.currentScale = 1;
568
- this.currentRotation = 0;
569
- this._updateInputs();
570
- this._updateMaskList();
571
- this.isImageLoadedToCanvas = true;
572
- this._updateUI();
573
- this.canvas.renderAll();
574
- try {
575
- this._lastSnapshot = this._serializeCanvasState();
576
- } catch (error) {
577
- this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
578
- }
579
- if (typeof this.onImageLoaded === "function") {
580
- this.onImageLoaded();
581
- }
582
- resolve();
583
- } catch (error) {
584
- reject(error);
585
- }
586
- }, { crossOrigin: "anonymous" });
587
- });
588
641
  }
589
642
  /**
590
643
  * Checks whether there is a loaded image on the current canvas.
@@ -609,8 +662,7 @@
609
662
  const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
610
663
  let timerId;
611
664
  const settle = (callback) => {
612
- if (isSettled)
613
- return;
665
+ if (isSettled) return;
614
666
  isSettled = true;
615
667
  clearTimeout(timerId);
616
668
  imageElement.onload = null;
@@ -622,6 +674,7 @@
622
674
  try {
623
675
  imageElement.src = "";
624
676
  } catch (error) {
677
+ void error;
625
678
  }
626
679
  }, safeTimeoutMs);
627
680
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -629,25 +682,132 @@
629
682
  imageElement.src = dataUrl;
630
683
  });
631
684
  }
685
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
686
+ return new Promise((resolve, reject) => {
687
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
688
+ let isSettled = false;
689
+ let timerId;
690
+ const settle = (callback) => {
691
+ if (isSettled) return;
692
+ isSettled = true;
693
+ clearTimeout(timerId);
694
+ callback();
695
+ };
696
+ timerId = setTimeout(() => {
697
+ settle(() => reject(new Error("Fabric image load timed out")));
698
+ }, safeTimeoutMs);
699
+ try {
700
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
701
+ settle(() => {
702
+ if (!fabricImage) {
703
+ reject(new Error("Image could not be loaded"));
704
+ return;
705
+ }
706
+ resolve(fabricImage);
707
+ });
708
+ }, { crossOrigin: "anonymous" });
709
+ } catch (error) {
710
+ settle(() => reject(error));
711
+ }
712
+ });
713
+ }
714
+ _getSafeTimeoutMs(timeoutMs) {
715
+ const safeTimeoutMs = Number(timeoutMs);
716
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
717
+ }
718
+ _captureLoadImageTransaction() {
719
+ return {
720
+ canvasState: this._serializeCanvasState(),
721
+ originalImage: this.originalImage,
722
+ baseImageScale: this.baseImageScale,
723
+ currentScale: this.currentScale,
724
+ currentRotation: this.currentRotation,
725
+ maskCounter: this.maskCounter,
726
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
727
+ lastSnapshot: this._lastSnapshot,
728
+ lastMask: this._lastMask,
729
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
730
+ lastMaskInitialTop: this._lastMaskInitialTop,
731
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
732
+ containerOverflow: this.containerElement && this.containerElement.style ? {
733
+ overflow: this.containerElement.style.overflow || "",
734
+ overflowX: this.containerElement.style.overflowX || "",
735
+ overflowY: this.containerElement.style.overflowY || ""
736
+ } : null,
737
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
738
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
739
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
740
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
741
+ };
742
+ }
743
+ async _rollbackLoadImageTransaction(transaction) {
744
+ if (!transaction || !this.canvas || this._disposed) return;
745
+ try {
746
+ if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
747
+ } catch (error) {
748
+ this._reportError("loadImage rollback failed", error);
749
+ }
750
+ this.baseImageScale = transaction.baseImageScale;
751
+ this.currentScale = transaction.currentScale;
752
+ this.currentRotation = transaction.currentRotation;
753
+ this.maskCounter = transaction.maskCounter;
754
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
755
+ this._lastSnapshot = transaction.lastSnapshot;
756
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
757
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
758
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
759
+ this._containerOriginalOverflow = transaction.containerOverflow;
760
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
761
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
762
+ if (this.containerElement) {
763
+ this.containerElement.scrollLeft = transaction.scrollLeft;
764
+ this.containerElement.scrollTop = transaction.scrollTop;
765
+ this._restoreContainerOverflowState();
766
+ }
767
+ this._updateInputs();
768
+ this._updateMaskList();
769
+ this._updateUI();
770
+ if (this.canvas) this.canvas.renderAll();
771
+ }
632
772
  /**
633
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
773
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
634
774
  *
635
775
  * @param {HTMLImageElement} imageElement - The image element to resample.
636
776
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
637
777
  * @param {number} targetHeight - Target height (in pixels) for the resampled image.
638
- * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
639
- * @returns {string} A data URL representing the resampled image as JPEG.
778
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
779
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
780
+ * @returns {string} A data URL representing the resampled image.
640
781
  * @private
641
782
  */
642
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
783
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
643
784
  const offscreenCanvas = document.createElement("canvas");
644
785
  offscreenCanvas.width = targetWidth;
645
786
  offscreenCanvas.height = targetHeight;
646
787
  const context = offscreenCanvas.getContext("2d");
647
- if (!context)
648
- throw new Error("2D canvas context is unavailable");
788
+ if (!context) throw new Error("2D canvas context is unavailable");
649
789
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
650
- return offscreenCanvas.toDataURL("image/jpeg", quality);
790
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
791
+ }
792
+ _getDataUrlMimeType(dataUrl) {
793
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
794
+ return match ? match[1].toLowerCase() : "";
795
+ }
796
+ _getDownsampleMimeType(sourceDataUrl) {
797
+ if (this.options.downsampleMimeType) {
798
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
799
+ return `image/${requestedFormat}`;
800
+ }
801
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
802
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
803
+ return sourceMimeType;
804
+ }
805
+ return "image/jpeg";
806
+ }
807
+ _captureCanvasStateOrThrow(context) {
808
+ const snapshot = this._serializeCanvasState();
809
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
810
+ return snapshot;
651
811
  }
652
812
  /**
653
813
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -662,19 +822,16 @@
662
822
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
663
823
  this.canvas.setWidth(integerWidth);
664
824
  this.canvas.setHeight(integerHeight);
665
- if (typeof this.canvas.calcOffset === "function")
666
- this.canvas.calcOffset();
825
+ if (typeof this.canvas.calcOffset === "function") this.canvas.calcOffset();
667
826
  if (this.canvasElement) {
668
827
  this.canvasElement.style.width = integerWidth + "px";
669
828
  this.canvasElement.style.height = integerHeight + "px";
670
- this.canvasElement.style.maxWidth = "none";
671
829
  }
672
830
  }
673
831
  _ceilCanvasDimension(value) {
674
832
  const numericValue = Number(value) || 0;
675
833
  const roundedValue = Math.round(numericValue);
676
- if (Math.abs(numericValue - roundedValue) < 0.01)
677
- return roundedValue;
834
+ if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
678
835
  return Math.ceil(numericValue);
679
836
  }
680
837
  _getContainerViewportSize() {
@@ -684,19 +841,36 @@
684
841
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
685
842
  };
686
843
  }
844
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
845
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
846
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
847
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
848
+ if (measuredWidth > 0 && measuredHeight > 0) {
849
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
850
+ }
687
851
  if (this._hasFixedContainerScrollbars()) {
688
- return {
689
- width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
690
- height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
691
- };
852
+ return { width, height };
853
+ }
854
+ const overflow = this._getContainerOverflowValues();
855
+ const canScrollX = overflow.x.some((value) => value === "auto" || value === "scroll");
856
+ const canScrollY = overflow.y.some((value) => value === "auto" || value === "scroll");
857
+ const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
858
+ const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
859
+ if (hasHorizontalScrollbar || hasVerticalScrollbar) {
860
+ const scrollbar = this._getScrollbarSize();
861
+ if (hasVerticalScrollbar) width += scrollbar.width;
862
+ if (hasHorizontalScrollbar) height += scrollbar.height;
692
863
  }
693
- const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
694
- const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
695
864
  return { width, height };
696
865
  }
697
- _hasFixedContainerScrollbars() {
698
- if (!this.containerElement)
699
- return false;
866
+ /**
867
+ * Reads inline and computed overflow values for both scroll axes.
868
+ *
869
+ * @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
870
+ * @private
871
+ */
872
+ _getContainerOverflowValues() {
873
+ if (!this.containerElement) return { x: [], y: [] };
700
874
  const inlineOverflow = this.containerElement.style.overflow;
701
875
  const inlineOverflowX = this.containerElement.style.overflowX;
702
876
  const inlineOverflowY = this.containerElement.style.overflowY;
@@ -709,7 +883,15 @@
709
883
  computedOverflowX = style.overflowX;
710
884
  computedOverflowY = style.overflowY;
711
885
  }
712
- return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
886
+ return {
887
+ x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
888
+ y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
889
+ };
890
+ }
891
+ _hasFixedContainerScrollbars() {
892
+ if (!this.containerElement) return false;
893
+ const overflow = this._getContainerOverflowValues();
894
+ return [...overflow.x, ...overflow.y].some((value) => value === "scroll");
713
895
  }
714
896
  _getScrollbarSize() {
715
897
  if (this._scrollbarSizeCache) {
@@ -752,15 +934,14 @@
752
934
  const scrollbar = this._getScrollbarSize();
753
935
  let hasVertical = false;
754
936
  let hasHorizontal = false;
755
- let effectiveWidth = viewport.width;
756
- let effectiveHeight = viewport.height;
937
+ let effectiveWidth;
938
+ let effectiveHeight;
757
939
  for (let i = 0; i < 4; i += 1) {
758
940
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
759
941
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
760
942
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
761
943
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
762
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
763
- break;
944
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
764
945
  hasVertical = nextHasVertical;
765
946
  hasHorizontal = nextHasHorizontal;
766
947
  }
@@ -797,8 +978,8 @@
797
978
  let scale = 1;
798
979
  let contentWidth = imageWidth;
799
980
  let contentHeight = imageHeight;
800
- let effectiveWidth = viewport.width;
801
- let effectiveHeight = viewport.height;
981
+ let effectiveWidth;
982
+ let effectiveHeight;
802
983
  for (let i = 0; i < 4; i += 1) {
803
984
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
804
985
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
@@ -807,8 +988,7 @@
807
988
  contentHeight = imageHeight * scale;
808
989
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
809
990
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
810
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
811
- break;
991
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
812
992
  hasVertical = nextHasVertical;
813
993
  hasHorizontal = nextHasHorizontal;
814
994
  }
@@ -847,41 +1027,48 @@
847
1027
  stroke: mask && mask.originalStroke || "#ccc",
848
1028
  strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
849
1029
  };
850
- if (Number.isFinite(opacity))
851
- style.opacity = opacity;
1030
+ if (Number.isFinite(opacity)) style.opacity = opacity;
852
1031
  return style;
853
1032
  }
854
1033
  _withNormalizedMaskStyles(callback) {
855
- if (!this.canvas)
856
- return callback();
1034
+ if (!this.canvas) return callback();
857
1035
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
858
- const maskStyleBackups = masks.map((mask) => ({
859
- object: mask,
860
- stroke: mask.stroke,
861
- strokeWidth: mask.strokeWidth,
862
- opacity: mask.opacity
863
- }));
1036
+ const maskStyleBackups = [];
864
1037
  try {
865
1038
  masks.forEach((mask) => {
866
- mask.set(this._getMaskNormalStyle(mask));
1039
+ const normalStyle = this._getMaskNormalStyle(mask);
1040
+ const stylePatch = {};
1041
+ Object.keys(normalStyle).forEach((property) => {
1042
+ if (mask[property] !== normalStyle[property]) {
1043
+ stylePatch[property] = normalStyle[property];
1044
+ }
1045
+ });
1046
+ const changedProperties = Object.keys(stylePatch);
1047
+ if (!changedProperties.length) return;
1048
+ const backup = { object: mask };
1049
+ changedProperties.forEach((property) => {
1050
+ backup[property] = mask[property];
1051
+ });
1052
+ maskStyleBackups.push(backup);
1053
+ mask.set(stylePatch);
867
1054
  });
868
1055
  return callback();
869
1056
  } finally {
870
1057
  maskStyleBackups.forEach((backup) => {
871
1058
  try {
872
- backup.object.set({
873
- stroke: backup.stroke,
874
- strokeWidth: backup.strokeWidth,
875
- opacity: backup.opacity
1059
+ const restorePatch = {};
1060
+ Object.keys(backup).forEach((property) => {
1061
+ if (property !== "object") restorePatch[property] = backup[property];
876
1062
  });
1063
+ backup.object.set(restorePatch);
877
1064
  } catch (error) {
1065
+ void error;
878
1066
  }
879
1067
  });
880
1068
  }
881
1069
  }
882
1070
  _restoreMaskControls(mask) {
883
- if (!mask)
884
- return;
1071
+ if (!mask) return;
885
1072
  const cornerSize = Number(mask.cornerSize);
886
1073
  mask.set({
887
1074
  selectable: mask.selectable !== false,
@@ -894,8 +1081,7 @@
894
1081
  transparentCorners: mask.transparentCorners === true,
895
1082
  strokeUniform: mask.strokeUniform !== false
896
1083
  });
897
- if (typeof mask.setCoords === "function")
898
- mask.setCoords();
1084
+ if (typeof mask.setCoords === "function") mask.setCoords();
899
1085
  }
900
1086
  /**
901
1087
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
@@ -917,8 +1103,7 @@
917
1103
  };
918
1104
  }
919
1105
  _serializeCanvasState() {
920
- if (!this.canvas)
921
- return null;
1106
+ if (!this.canvas) return null;
922
1107
  return this._withNormalizedMaskStyles(() => {
923
1108
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
924
1109
  if (Array.isArray(jsonObject.objects)) {
@@ -937,8 +1122,7 @@
937
1122
  */
938
1123
  _normalizeQuality(quality) {
939
1124
  const numericQuality = Number(quality);
940
- if (!Number.isFinite(numericQuality))
941
- return this.options.downsampleQuality ?? 0.92;
1125
+ if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
942
1126
  return Math.max(0, Math.min(1, numericQuality));
943
1127
  }
944
1128
  /**
@@ -1011,8 +1195,7 @@
1011
1195
  const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1012
1196
  let timerId;
1013
1197
  const settle = (callback) => {
1014
- if (isSettled)
1015
- return;
1198
+ if (isSettled) return;
1016
1199
  isSettled = true;
1017
1200
  clearTimeout(timerId);
1018
1201
  imageElement.onload = null;
@@ -1024,6 +1207,7 @@
1024
1207
  try {
1025
1208
  imageElement.src = "";
1026
1209
  } catch (error) {
1210
+ void error;
1027
1211
  }
1028
1212
  }, safeTimeoutMs);
1029
1213
  imageElement.onload = () => {
@@ -1037,8 +1221,7 @@
1037
1221
  offscreenCanvas.width = scaledSourceWidth;
1038
1222
  offscreenCanvas.height = scaledSourceHeight;
1039
1223
  const context = offscreenCanvas.getContext("2d");
1040
- if (!context)
1041
- throw new Error("2D canvas context is unavailable");
1224
+ if (!context) throw new Error("2D canvas context is unavailable");
1042
1225
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1043
1226
  settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1044
1227
  } catch (error) {
@@ -1050,7 +1233,7 @@
1050
1233
  });
1051
1234
  }
1052
1235
  /**
1053
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1236
+ * Exports a source region directly through Fabric's region export options.
1054
1237
  *
1055
1238
  * @param {Object} region - Canvas source region and export options.
1056
1239
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1063,14 +1246,17 @@
1063
1246
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1064
1247
  * @private
1065
1248
  */
1066
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1249
+ _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1067
1250
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1068
- const fullDataUrl = this.canvas.toDataURL({
1251
+ return this.canvas.toDataURL({
1069
1252
  format,
1070
1253
  quality,
1071
- multiplier: safeMultiplier
1254
+ multiplier: safeMultiplier,
1255
+ left: sourceX,
1256
+ top: sourceY,
1257
+ width: sourceWidth,
1258
+ height: sourceHeight
1072
1259
  });
1073
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1074
1260
  }
1075
1261
  /**
1076
1262
  * Gets the top-left corner coordinates of the given object.
@@ -1081,15 +1267,39 @@
1081
1267
  * @private
1082
1268
  */
1083
1269
  _getObjectTopLeftPoint(fabricObject) {
1084
- if (!fabricObject)
1085
- return { x: 0, y: 0 };
1270
+ if (!fabricObject) return { x: 0, y: 0 };
1086
1271
  fabricObject.setCoords();
1087
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1088
- if (coords && coords.length)
1089
- return coords[0];
1090
1272
  const boundingRect = fabricObject.getBoundingRect(true, true);
1091
1273
  return { x: boundingRect.left, y: boundingRect.top };
1092
1274
  }
1275
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1276
+ if (!fabricObject) return { x: 0, y: 0 };
1277
+ fabricObject.setCoords();
1278
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1279
+ if (coords && coords.length) return coords[0];
1280
+ return this._getObjectTopLeftPoint(fabricObject);
1281
+ }
1282
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1283
+ if (!fabricObject) return { x: 0, y: 0 };
1284
+ if (typeof fabricObject.getPointByOrigin === "function") {
1285
+ return fabricObject.getPointByOrigin(originX, originY);
1286
+ }
1287
+ return this._getObjectTopLeftPoint(fabricObject);
1288
+ }
1289
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1290
+ if (!fabricObject) return;
1291
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1292
+ const center = fabricObject.getCenterPoint();
1293
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1294
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1295
+ } else {
1296
+ fabricObject.set({
1297
+ left: (fabricObject.left || 0) + deltaX,
1298
+ top: (fabricObject.top || 0) + deltaY
1299
+ });
1300
+ }
1301
+ fabricObject.setCoords();
1302
+ }
1093
1303
  /**
1094
1304
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1095
1305
  *
@@ -1100,8 +1310,7 @@
1100
1310
  * @private
1101
1311
  */
1102
1312
  _setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
1103
- if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
1104
- return;
1313
+ if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
1105
1314
  fabricObject.set({ originX, originY });
1106
1315
  fabricObject.setPositionByOrigin(refPoint, originX, originY);
1107
1316
  fabricObject.setCoords();
@@ -1113,8 +1322,7 @@
1113
1322
  * @private
1114
1323
  */
1115
1324
  _alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
1116
- if (!fabricObject)
1117
- return;
1325
+ if (!fabricObject) return;
1118
1326
  fabricObject.setCoords();
1119
1327
  const boundingRect = fabricObject.getBoundingRect(true, true);
1120
1328
  const deltaX = boundingRect.left;
@@ -1129,8 +1337,7 @@
1129
1337
  * @private
1130
1338
  */
1131
1339
  _updateCanvasSizeToImageBounds() {
1132
- if (!this.originalImage)
1133
- return;
1340
+ if (!this.originalImage) return;
1134
1341
  this.originalImage.setCoords();
1135
1342
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1136
1343
  const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
@@ -1154,25 +1361,34 @@
1154
1361
  * @private
1155
1362
  */
1156
1363
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1157
- if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds())
1158
- return;
1364
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1159
1365
  try {
1160
- let requiredWidth = this.canvas.getWidth();
1161
- let requiredHeight = this.canvas.getHeight();
1366
+ const currentWidth = this.canvas.getWidth();
1367
+ const currentHeight = this.canvas.getHeight();
1368
+ let requiredWidth = currentWidth;
1369
+ let requiredHeight = currentHeight;
1162
1370
  fabricObjects.forEach((fabricObject) => {
1163
- if (!fabricObject)
1164
- return;
1165
- if (typeof fabricObject.setCoords === "function")
1166
- fabricObject.setCoords();
1371
+ if (!fabricObject) return;
1372
+ if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1167
1373
  const boundingRect = fabricObject.getBoundingRect(true, true);
1168
1374
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1169
1375
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1170
1376
  });
1171
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1172
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1173
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1174
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1175
- if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1377
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1378
+ let minWidth = 0;
1379
+ let minHeight = 0;
1380
+ if (shouldUseScrollSafeViewport) {
1381
+ const viewport = this._getContainerViewportSize();
1382
+ const safetyMargin = this._getScrollSafetyMargin();
1383
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1384
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1385
+ } else if (this.containerElement) {
1386
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1387
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1388
+ }
1389
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1390
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1391
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1176
1392
  this._setCanvasSizeInt(newWidth, newHeight);
1177
1393
  }
1178
1394
  } catch (error) {
@@ -1200,6 +1416,66 @@
1200
1416
  scaleImage(factor, options = {}) {
1201
1417
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1202
1418
  }
1419
+ _assertIdleForOperation(operationName) {
1420
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1421
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1422
+ throw new Error(`${operationName} cannot run while an animation is running`);
1423
+ }
1424
+ }
1425
+ _canMutateNow(operationName) {
1426
+ try {
1427
+ this._assertIdleForOperation(operationName);
1428
+ return true;
1429
+ } catch (error) {
1430
+ this._reportError(`${operationName} blocked`, error);
1431
+ return false;
1432
+ }
1433
+ }
1434
+ _rejectActiveAnimations(reason) {
1435
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1436
+ this._activeAnimationRejectors.forEach((reject) => {
1437
+ try {
1438
+ reject(error);
1439
+ } catch (rejectError) {
1440
+ void rejectError;
1441
+ }
1442
+ });
1443
+ this._activeAnimationRejectors.clear();
1444
+ }
1445
+ _animateFabricProperty(fabricObject, property, value) {
1446
+ return new Promise((resolve, reject) => {
1447
+ if (this._disposed || !this.canvas || !fabricObject) {
1448
+ reject(new Error("Animation cannot start after editor disposal"));
1449
+ return;
1450
+ }
1451
+ let isSettled = false;
1452
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1453
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1454
+ let timerId;
1455
+ const settle = (callback) => {
1456
+ if (isSettled) return;
1457
+ isSettled = true;
1458
+ clearTimeout(timerId);
1459
+ this._activeAnimationRejectors.delete(reject);
1460
+ callback();
1461
+ };
1462
+ this._activeAnimationRejectors.add(reject);
1463
+ timerId = setTimeout(() => {
1464
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1465
+ }, timeoutMs);
1466
+ try {
1467
+ fabricObject.animate(property, value, {
1468
+ duration,
1469
+ onChange: () => {
1470
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1471
+ },
1472
+ onComplete: () => settle(resolve)
1473
+ });
1474
+ } catch (error) {
1475
+ settle(() => reject(error));
1476
+ }
1477
+ });
1478
+ }
1203
1479
  /**
1204
1480
  * Scales the original image by a given factor, with animation.
1205
1481
  * Returns a promise that resolves when the scale animation is complete.
@@ -1207,34 +1483,25 @@
1207
1483
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1208
1484
  * @private
1209
1485
  */
1210
- _scaleImageImpl(factor, options = {}) {
1211
- if (!this.originalImage)
1212
- return Promise.resolve();
1213
- if (this.isAnimating)
1214
- return Promise.resolve();
1486
+ async _scaleImageImpl(factor, options = {}) {
1487
+ if (!this.originalImage || this._disposed) return;
1488
+ if (this.isAnimating) return;
1215
1489
  const saveHistory = options.saveHistory !== false;
1216
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1217
- this.currentScale = factor;
1218
- this.isAnimating = true;
1219
- this._updateUI();
1220
- const targetScale = this.baseImageScale * factor;
1221
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1222
- this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1223
- const scaleXAnimation = new Promise((resolve) => {
1224
- this.originalImage.animate("scaleX", targetScale, {
1225
- duration: this.options.animationDuration,
1226
- onChange: this.canvas.renderAll.bind(this.canvas),
1227
- onComplete: resolve
1228
- });
1229
- });
1230
- const scaleYAnimation = new Promise((resolve) => {
1231
- this.originalImage.animate("scaleY", targetScale, {
1232
- duration: this.options.animationDuration,
1233
- onChange: this.canvas.renderAll.bind(this.canvas),
1234
- onComplete: resolve
1235
- });
1236
- });
1237
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1490
+ let didStartAnimation = false;
1491
+ try {
1492
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1493
+ this.currentScale = factor;
1494
+ this.isAnimating = true;
1495
+ didStartAnimation = true;
1496
+ this._updateUI();
1497
+ const targetScale = this.baseImageScale * factor;
1498
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1499
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1500
+ await Promise.all([
1501
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1502
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1503
+ ]);
1504
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1238
1505
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1239
1506
  this.originalImage.setCoords();
1240
1507
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1242,18 +1509,17 @@
1242
1509
  }
1243
1510
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1244
1511
  this.canvas.getObjects().forEach((object) => {
1245
- if (object.maskId)
1246
- this._syncMaskLabel(object);
1512
+ if (object.maskId) this._syncMaskLabel(object);
1247
1513
  });
1248
- this.isAnimating = false;
1249
1514
  this._updateInputs();
1250
- this._updateUI();
1251
- if (saveHistory)
1252
- this.saveState();
1253
- }).catch(() => {
1254
- this.isAnimating = false;
1255
- this._updateUI();
1256
- });
1515
+ if (saveHistory) this.saveState();
1516
+ } finally {
1517
+ if (didStartAnimation) {
1518
+ this.isAnimating = false;
1519
+ this._updateInputs();
1520
+ this._updateUI();
1521
+ }
1522
+ }
1257
1523
  }
1258
1524
  /**
1259
1525
  * Rotates the original image by a given number of degrees, with animation.
@@ -1272,48 +1538,50 @@
1272
1538
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1273
1539
  * @private
1274
1540
  */
1275
- _rotateImageImpl(degrees, options = {}) {
1276
- if (!this.originalImage)
1277
- return Promise.resolve();
1278
- if (this.isAnimating)
1279
- return Promise.resolve();
1280
- if (isNaN(degrees))
1281
- return Promise.resolve();
1541
+ async _rotateImageImpl(degrees, options = {}) {
1542
+ if (!this.originalImage || this._disposed) return;
1543
+ if (this.isAnimating) return;
1544
+ if (isNaN(degrees)) return;
1282
1545
  const saveHistory = options.saveHistory !== false;
1283
- this.currentRotation = degrees;
1284
- this.isAnimating = true;
1285
- this._updateUI();
1286
- const center = this.originalImage.getCenterPoint();
1287
- this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
1288
- const rotationAnimation = new Promise((resolve) => {
1289
- this.originalImage.animate("angle", degrees, {
1290
- duration: this.options.animationDuration,
1291
- onChange: this.canvas.renderAll.bind(this.canvas),
1292
- onComplete: resolve
1293
- });
1294
- });
1295
- return rotationAnimation.then(() => {
1546
+ const image = this.originalImage;
1547
+ const previousOriginX = image.originX || "left";
1548
+ const previousOriginY = image.originY || "top";
1549
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1550
+ let didStartAnimation = false;
1551
+ let didCompleteRotation = false;
1552
+ try {
1553
+ this.currentRotation = degrees;
1554
+ this.isAnimating = true;
1555
+ didStartAnimation = true;
1556
+ this._updateUI();
1557
+ const center = image.getCenterPoint();
1558
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1559
+ await this._animateFabricProperty(image, "angle", degrees);
1560
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1296
1561
  this.originalImage.set("angle", degrees);
1297
1562
  this.originalImage.setCoords();
1298
1563
  if (this._shouldResizeCanvasToContentBounds()) {
1299
1564
  this._updateCanvasSizeToImageBounds();
1300
1565
  }
1301
1566
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1302
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1567
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1303
1568
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1304
1569
  this.canvas.getObjects().forEach((object) => {
1305
- if (object.maskId)
1306
- this._syncMaskLabel(object);
1570
+ if (object.maskId) this._syncMaskLabel(object);
1307
1571
  });
1308
- this.isAnimating = false;
1309
1572
  this._updateInputs();
1310
- this._updateUI();
1311
- if (saveHistory)
1312
- this.saveState();
1313
- }).catch(() => {
1314
- this.isAnimating = false;
1315
- this._updateUI();
1316
- });
1573
+ if (saveHistory) this.saveState();
1574
+ didCompleteRotation = true;
1575
+ } finally {
1576
+ if (!didCompleteRotation && !this._disposed && image) {
1577
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1578
+ }
1579
+ if (didStartAnimation) {
1580
+ this.isAnimating = false;
1581
+ this._updateInputs();
1582
+ this._updateUI();
1583
+ }
1584
+ }
1317
1585
  }
1318
1586
  /**
1319
1587
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1322,16 +1590,16 @@
1322
1590
  * @public
1323
1591
  */
1324
1592
  resetImageTransform() {
1325
- if (!this.originalImage)
1326
- return Promise.resolve();
1593
+ if (!this.originalImage) return Promise.resolve();
1327
1594
  return this.animationQueue.add(async () => {
1328
- const before = this._lastSnapshot || this._serializeCanvasState();
1595
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1329
1596
  await this._scaleImageImpl(1, { saveHistory: false });
1330
1597
  await this._rotateImageImpl(0, { saveHistory: false });
1331
- const after = this._serializeCanvasState();
1598
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1332
1599
  this._pushStateTransition(before, after);
1333
1600
  }).catch((error) => {
1334
1601
  this._reportError("resetImageTransform() failed", error);
1602
+ throw error;
1335
1603
  });
1336
1604
  }
1337
1605
  /**
@@ -1351,14 +1619,31 @@
1351
1619
  * @public
1352
1620
  */
1353
1621
  loadFromState(serializedState) {
1354
- if (!serializedState || !this.canvas)
1355
- return Promise.resolve();
1356
- return new Promise((resolve) => {
1622
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1623
+ if (this._cropMode || this._cropRect) {
1624
+ this._removeCropRect();
1625
+ this._restoreCropObjectState();
1626
+ this._cropMode = false;
1627
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1628
+ this.canvas.selection = !!this._prevSelectionSetting;
1629
+ }
1630
+ this._prevSelectionSetting = void 0;
1631
+ }
1632
+ return new Promise((resolve, reject) => {
1357
1633
  try {
1358
1634
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1359
1635
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1360
- this.canvas.loadFromJSON(state, () => {
1636
+ this.canvas.loadFromJSON(state, async () => {
1361
1637
  try {
1638
+ if (this._disposed || !this.canvas) {
1639
+ reject(new Error("Editor was disposed while loading state"));
1640
+ return;
1641
+ }
1642
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1643
+ if (this._disposed || !this.canvas) {
1644
+ reject(new Error("Editor was disposed while loading state"));
1645
+ return;
1646
+ }
1362
1647
  this._hideAllMaskLabels();
1363
1648
  const canvasObjects = this.canvas.getObjects();
1364
1649
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1406,18 +1691,44 @@
1406
1691
  this._updatePlaceholderStatus();
1407
1692
  this._lastSnapshot = this._serializeCanvasState();
1408
1693
  this._updateUI();
1694
+ resolve();
1409
1695
  } catch (callbackError) {
1410
1696
  this._reportError("loadFromState() failed", callbackError);
1411
- } finally {
1412
- resolve();
1697
+ reject(callbackError);
1413
1698
  }
1414
1699
  });
1415
1700
  } catch (error) {
1416
1701
  this._reportError("loadFromState() failed", error);
1417
- resolve();
1702
+ reject(error);
1418
1703
  }
1419
1704
  });
1420
1705
  }
1706
+ async _waitForFabricImagesReady(canvasObjects) {
1707
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1708
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1709
+ typeof object.getElement === "function" ? object.getElement() : object._element
1710
+ )));
1711
+ }
1712
+ _waitForImageElementReady(imageElement) {
1713
+ if (!imageElement) return Promise.resolve();
1714
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1715
+ return new Promise((resolve, reject) => {
1716
+ let isSettled = false;
1717
+ const timerId = setTimeout(() => {
1718
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1719
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1720
+ const settle = (callback) => {
1721
+ if (isSettled) return;
1722
+ isSettled = true;
1723
+ clearTimeout(timerId);
1724
+ imageElement.onload = null;
1725
+ imageElement.onerror = null;
1726
+ callback();
1727
+ };
1728
+ imageElement.onload = () => settle(resolve);
1729
+ imageElement.onerror = (error) => settle(() => reject(error));
1730
+ });
1731
+ }
1421
1732
  /**
1422
1733
  * Saves the current editable canvas state as an undoable history transition.
1423
1734
  *
@@ -1428,14 +1739,11 @@
1428
1739
  * @public
1429
1740
  */
1430
1741
  saveState() {
1431
- if (!this.canvas)
1432
- return;
1433
- const activeObject = this.canvas.getActiveObject();
1742
+ if (!this.canvas) return;
1434
1743
  try {
1435
- const after = this._serializeCanvasState();
1744
+ const after = this._captureCanvasStateOrThrow("saveState");
1436
1745
  const before = this._lastSnapshot || after;
1437
- if (after === before)
1438
- return;
1746
+ if (after === before) return;
1439
1747
  let executedOnce = false;
1440
1748
  const command = new Command(
1441
1749
  () => {
@@ -1452,9 +1760,6 @@
1452
1760
  } catch (error) {
1453
1761
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1454
1762
  } finally {
1455
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1456
- this._handleSelectionChanged([activeObject]);
1457
- }
1458
1763
  this._updateUI();
1459
1764
  }
1460
1765
  }
@@ -1470,12 +1775,12 @@
1470
1775
  * @private
1471
1776
  */
1472
1777
  _pushStateTransition(before, after) {
1473
- if (!before || !after)
1474
- return;
1475
- if (before === after)
1778
+ if (!before || !after) {
1779
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1476
1780
  return;
1477
- if (!this.historyManager)
1478
- this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1781
+ }
1782
+ if (before === after) return;
1783
+ if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1479
1784
  const command = new Command(
1480
1785
  () => this.loadFromState(after),
1481
1786
  () => this.loadFromState(before)
@@ -1495,6 +1800,7 @@
1495
1800
  this._updateUI();
1496
1801
  }).catch((error) => {
1497
1802
  this._reportError("undo failed", error);
1803
+ throw error;
1498
1804
  });
1499
1805
  }
1500
1806
  /**
@@ -1508,48 +1814,40 @@
1508
1814
  this._updateUI();
1509
1815
  }).catch((error) => {
1510
1816
  this._reportError("redo failed", error);
1817
+ throw error;
1511
1818
  });
1512
1819
  }
1513
1820
  _rebindMaskEvents(mask) {
1514
- if (!mask)
1515
- return;
1821
+ if (!mask) return;
1516
1822
  if (mask.__imageEditorMaskHandlers) {
1517
1823
  try {
1518
1824
  mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
1519
1825
  mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
1520
1826
  } catch (error) {
1827
+ void error;
1521
1828
  }
1522
1829
  }
1523
1830
  const metadata = {};
1524
1831
  if (!Number.isFinite(Number(mask.originalAlpha))) {
1525
1832
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
1526
1833
  }
1527
- if (!mask.originalStroke)
1528
- metadata.originalStroke = mask.stroke || "#ccc";
1834
+ if (!mask.originalStroke) metadata.originalStroke = mask.stroke || "#ccc";
1529
1835
  if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
1530
1836
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1531
1837
  }
1532
- if (Object.keys(metadata).length)
1533
- mask.set(metadata);
1534
- const normalStyle = {
1535
- stroke: mask.originalStroke || "#ccc",
1536
- strokeWidth: mask.originalStrokeWidth,
1537
- opacity: mask.originalAlpha
1538
- };
1539
- const hoverStyle = {
1540
- stroke: "#ff5500",
1541
- strokeWidth: 2,
1542
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1543
- };
1838
+ if (Object.keys(metadata).length) mask.set(metadata);
1544
1839
  const mouseover = () => {
1545
- mask.set(hoverStyle);
1546
- if (mask.canvas)
1547
- mask.canvas.requestRenderAll();
1840
+ const opacity = Number(mask.originalAlpha);
1841
+ mask.set({
1842
+ stroke: "#ff5500",
1843
+ strokeWidth: 2,
1844
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
1845
+ });
1846
+ if (mask.canvas) mask.canvas.requestRenderAll();
1548
1847
  };
1549
1848
  const mouseout = () => {
1550
- mask.set(normalStyle);
1551
- if (mask.canvas)
1552
- mask.canvas.requestRenderAll();
1849
+ mask.set(this._getMaskNormalStyle(mask));
1850
+ if (mask.canvas) mask.canvas.requestRenderAll();
1553
1851
  };
1554
1852
  mask.on("mouseover", mouseover);
1555
1853
  mask.on("mouseout", mouseout);
@@ -1584,8 +1882,8 @@
1584
1882
  * @public
1585
1883
  */
1586
1884
  createMask(config = {}) {
1587
- if (!this.canvas)
1588
- return null;
1885
+ if (!this.canvas) return null;
1886
+ if (!this._canMutateNow("createMask")) return null;
1589
1887
  const shapeType = config.shape || "rect";
1590
1888
  const maskConfig = {
1591
1889
  shape: shapeType,
@@ -1601,33 +1899,37 @@
1601
1899
  ...config
1602
1900
  };
1603
1901
  const firstOffset = 10;
1604
- let left = firstOffset;
1605
- let top = firstOffset;
1606
- const resolveValue = (value, fallback) => {
1902
+ let left;
1903
+ let top;
1904
+ const getCanvasBasis = (axis) => {
1905
+ const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
1906
+ const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
1907
+ if (axis === "height") return canvasHeight;
1908
+ if (axis === "min") return Math.min(canvasWidth, canvasHeight);
1909
+ return canvasWidth;
1910
+ };
1911
+ const resolveValue = (value, fallback, axis = "width") => {
1607
1912
  if (typeof value === "function")
1608
1913
  return value(this.canvas, this.options);
1609
1914
  if (typeof value === "string" && value.endsWith("%")) {
1610
- const percent = parseFloat(value) / 100;
1611
- return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1915
+ const percent = Number.parseFloat(value) / 100;
1916
+ if (!Number.isFinite(percent)) return fallback;
1917
+ return Math.floor(getCanvasBasis(axis) * percent);
1612
1918
  }
1613
1919
  return value != null ? value : fallback;
1614
1920
  };
1615
1921
  if (maskConfig.left === void 0 && this._lastMask) {
1616
1922
  const previousMask = this._lastMask;
1617
- let previousMaskRight = previousMask.left;
1618
- if (previousMask.getScaledWidth) {
1619
- previousMaskRight += previousMask.getScaledWidth();
1620
- } else if (previousMask.width) {
1621
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1622
- }
1623
- left = Math.round(previousMaskRight + maskConfig.gap);
1624
- top = previousMask.top ?? firstOffset;
1923
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
1924
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
1925
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
1926
+ top = Math.round(previousBounds.top ?? firstOffset);
1625
1927
  } else {
1626
- left = resolveValue(maskConfig.left, firstOffset);
1627
- top = resolveValue(maskConfig.top, firstOffset);
1928
+ left = resolveValue(maskConfig.left, firstOffset, "width");
1929
+ top = resolveValue(maskConfig.top, firstOffset, "height");
1628
1930
  }
1629
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1630
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1931
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1932
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
1631
1933
  maskConfig.left = left;
1632
1934
  maskConfig.top = top;
1633
1935
  let mask;
@@ -1639,7 +1941,7 @@
1639
1941
  mask = new fabric.Circle({
1640
1942
  left,
1641
1943
  top,
1642
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1944
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
1643
1945
  fill: maskConfig.color,
1644
1946
  opacity: maskConfig.alpha,
1645
1947
  angle: maskConfig.angle,
@@ -1650,8 +1952,8 @@
1650
1952
  mask = new fabric.Ellipse({
1651
1953
  left,
1652
1954
  top,
1653
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1654
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1955
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
1956
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
1655
1957
  fill: maskConfig.color,
1656
1958
  opacity: maskConfig.alpha,
1657
1959
  angle: maskConfig.angle,
@@ -1678,8 +1980,8 @@
1678
1980
  mask = new fabric.Rect({
1679
1981
  left,
1680
1982
  top,
1681
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1682
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1983
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
1984
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
1683
1985
  fill: maskConfig.color,
1684
1986
  opacity: maskConfig.alpha,
1685
1987
  angle: maskConfig.angle,
@@ -1704,8 +2006,7 @@
1704
2006
  opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
1705
2007
  strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
1706
2008
  };
1707
- if (hasStyle("strokeDashArray"))
1708
- maskSettings.strokeDashArray = styles.strokeDashArray;
2009
+ if (hasStyle("strokeDashArray")) maskSettings.strokeDashArray = styles.strokeDashArray;
1709
2010
  mask.set(maskSettings);
1710
2011
  mask.setCoords();
1711
2012
  mask.set({
@@ -1717,7 +2018,7 @@
1717
2018
  this._expandCanvasToFitObject(mask);
1718
2019
  this._lastMaskInitialLeft = left;
1719
2020
  this._lastMaskInitialTop = top;
1720
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
2021
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1721
2022
  const maskId = ++this.maskCounter;
1722
2023
  mask.set({
1723
2024
  maskId,
@@ -1726,15 +2027,13 @@
1726
2027
  this._lastMask = mask;
1727
2028
  this.canvas.add(mask);
1728
2029
  this.canvas.bringToFront(mask);
1729
- if (maskConfig.selectable)
1730
- this.canvas.setActiveObject(mask);
2030
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1731
2031
  this._handleSelectionChanged([mask]);
1732
2032
  this._updateMaskList();
1733
2033
  this._updateUI();
1734
2034
  this.canvas.renderAll();
1735
2035
  this.saveState();
1736
- if (typeof maskConfig.onCreate === "function")
1737
- maskConfig.onCreate(mask, this.canvas);
2036
+ if (typeof maskConfig.onCreate === "function") maskConfig.onCreate(mask, this.canvas);
1738
2037
  return mask;
1739
2038
  }
1740
2039
  /**
@@ -1752,10 +2051,11 @@
1752
2051
  * The associated label is also removed. UI and mask list are updated.
1753
2052
  */
1754
2053
  removeSelectedMask() {
2054
+ if (!this.canvas) return;
2055
+ if (!this._canMutateNow("removeSelectedMask")) return;
1755
2056
  const activeObject = this.canvas.getActiveObject();
1756
2057
  const selectedMasks = this._getModifiedMasks(activeObject);
1757
- if (!selectedMasks.length)
1758
- return;
2058
+ if (!selectedMasks.length) return;
1759
2059
  this.canvas.discardActiveObject();
1760
2060
  selectedMasks.forEach((mask) => {
1761
2061
  this._removeLabelForMask(mask);
@@ -1778,6 +2078,8 @@
1778
2078
  * UI and internal mask placement memory are reset.
1779
2079
  */
1780
2080
  removeAllMasks(options = {}) {
2081
+ if (!this.canvas) return;
2082
+ if (!this._canMutateNow("removeAllMasks")) return;
1781
2083
  const saveHistory = options.saveHistory !== false;
1782
2084
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1783
2085
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1790,8 +2092,7 @@
1790
2092
  this._updateMaskList();
1791
2093
  this._updateUI();
1792
2094
  this.canvas.renderAll();
1793
- if (saveHistory)
1794
- this.saveState();
2095
+ if (saveHistory) this.saveState();
1795
2096
  }
1796
2097
  /**
1797
2098
  * Removes the label associated with the specified mask object, if it exists.
@@ -1800,8 +2101,7 @@
1800
2101
  * @private
1801
2102
  */
1802
2103
  _removeLabelForMask(mask) {
1803
- if (!mask || !this.canvas)
1804
- return;
2104
+ if (!mask || !this.canvas) return;
1805
2105
  if (mask.__label) {
1806
2106
  try {
1807
2107
  const canvasObjects = this.canvas.getObjects();
@@ -1809,10 +2109,12 @@
1809
2109
  this.canvas.remove(mask.__label);
1810
2110
  }
1811
2111
  } catch (error) {
2112
+ void error;
1812
2113
  }
1813
2114
  try {
1814
2115
  delete mask.__label;
1815
2116
  } catch (error) {
2117
+ void error;
1816
2118
  }
1817
2119
  }
1818
2120
  }
@@ -1828,8 +2130,7 @@
1828
2130
  */
1829
2131
  _getMaskCreationIndex(mask) {
1830
2132
  const maskId = Number(mask && mask.maskId);
1831
- if (Number.isFinite(maskId) && maskId > 0)
1832
- return Math.floor(maskId) - 1;
2133
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
1833
2134
  const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
1834
2135
  return Math.max(0, masks.indexOf(mask));
1835
2136
  }
@@ -1841,12 +2142,15 @@
1841
2142
  * @private
1842
2143
  */
1843
2144
  _createLabelForMask(mask) {
1844
- if (!mask || !this.options.maskLabelOnSelect)
1845
- return;
2145
+ if (!mask || !this.options.maskLabelOnSelect) return;
1846
2146
  this._removeLabelForMask(mask);
1847
2147
  let textObject = null;
1848
2148
  if (this.options.label && typeof this.options.label.create === "function") {
1849
2149
  textObject = this.options.label.create(mask, fabric);
2150
+ if (!textObject || typeof textObject.set !== "function") {
2151
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2152
+ textObject = null;
2153
+ }
1850
2154
  }
1851
2155
  if (!textObject) {
1852
2156
  let labelText = mask.maskName;
@@ -1884,15 +2188,14 @@
1884
2188
  * @private
1885
2189
  */
1886
2190
  _hideAllMaskLabels() {
1887
- if (!this.canvas)
1888
- return;
2191
+ if (!this.canvas) return;
1889
2192
  const canvasObjects = this.canvas.getObjects();
1890
2193
  const labels = canvasObjects.filter((object) => object.maskLabel);
1891
2194
  labels.forEach((label) => {
1892
2195
  try {
1893
- if (canvasObjects.includes(label))
1894
- this.canvas.remove(label);
2196
+ if (canvasObjects.includes(label)) this.canvas.remove(label);
1895
2197
  } catch (error) {
2198
+ void error;
1896
2199
  }
1897
2200
  });
1898
2201
  canvasObjects.forEach((object) => {
@@ -1900,6 +2203,7 @@
1900
2203
  try {
1901
2204
  delete object.__label;
1902
2205
  } catch (error) {
2206
+ void error;
1903
2207
  }
1904
2208
  }
1905
2209
  });
@@ -1911,16 +2215,13 @@
1911
2215
  * @private
1912
2216
  */
1913
2217
  _syncMaskLabel(mask) {
1914
- if (!mask)
1915
- return;
1916
- if (!this.options.maskLabelOnSelect)
1917
- return;
1918
- if (!mask.__label)
1919
- return;
1920
- const coords = mask.getCoords ? mask.getCoords() : null;
1921
- if (!coords || coords.length < 4)
1922
- return;
1923
- const tl = coords[0];
2218
+ if (!mask) return;
2219
+ if (!this.options.maskLabelOnSelect) return;
2220
+ if (!mask.__label) return;
2221
+ if (typeof mask.setCoords === "function") mask.setCoords();
2222
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2223
+ if (!bounds) return;
2224
+ const tl = { x: bounds.left, y: bounds.top };
1924
2225
  const center = mask.getCenterPoint();
1925
2226
  const vx = center.x - tl.x;
1926
2227
  const vy = center.y - tl.y;
@@ -1952,12 +2253,9 @@
1952
2253
  * @private
1953
2254
  */
1954
2255
  _showLabelForMask(mask) {
1955
- if (!mask)
1956
- return;
1957
- if (!this.options.maskLabelOnSelect)
1958
- return;
1959
- if (!mask.__label)
1960
- this._createLabelForMask(mask);
2256
+ if (!mask) return;
2257
+ if (!this.options.maskLabelOnSelect) return;
2258
+ if (!mask.__label) this._createLabelForMask(mask);
1961
2259
  mask.__label.set({ visible: true });
1962
2260
  this._syncMaskLabel(mask);
1963
2261
  }
@@ -1977,6 +2275,7 @@
1977
2275
  try {
1978
2276
  this.canvas.remove(mask.__label);
1979
2277
  } catch (error) {
2278
+ void error;
1980
2279
  }
1981
2280
  delete mask.__label;
1982
2281
  }
@@ -1989,8 +2288,7 @@
1989
2288
  mask.set({ stroke: "#ff0000", strokeWidth: 1 });
1990
2289
  }
1991
2290
  });
1992
- if (selectedMask)
1993
- this._showLabelForMask(selectedMask);
2291
+ if (selectedMask) this._showLabelForMask(selectedMask);
1994
2292
  this._updateMaskListSelection(selectedMask);
1995
2293
  this.canvas.renderAll();
1996
2294
  this._updateUI();
@@ -2001,22 +2299,28 @@
2001
2299
  * @private
2002
2300
  */
2003
2301
  _updateMaskList() {
2004
- const maskListElement = document.getElementById(this.elements.maskList);
2005
- if (!maskListElement)
2006
- return;
2302
+ const maskListElement = this._getElement("maskList");
2303
+ if (!maskListElement) return;
2007
2304
  maskListElement.innerHTML = "";
2008
2305
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2009
2306
  masks.forEach((mask) => {
2010
2307
  const listItemElement = document.createElement("li");
2011
2308
  listItemElement.className = "list-group-item mask-item";
2012
2309
  listItemElement.textContent = mask.maskName;
2013
- listItemElement.onclick = () => {
2014
- this.canvas.setActiveObject(mask);
2015
- this._handleSelectionChanged([mask]);
2016
- };
2310
+ listItemElement.dataset.maskId = String(mask.maskId);
2017
2311
  maskListElement.appendChild(listItemElement);
2018
2312
  });
2019
2313
  }
2314
+ _handleMaskListClick(event) {
2315
+ if (!this.canvas) return;
2316
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2317
+ if (!itemElement || !itemElement.dataset) return;
2318
+ const maskId = Number(itemElement.dataset.maskId);
2319
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2320
+ if (!mask) return;
2321
+ this.canvas.setActiveObject(mask);
2322
+ this._handleSelectionChanged([mask]);
2323
+ }
2020
2324
  /**
2021
2325
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
2022
2326
  *
@@ -2024,13 +2328,13 @@
2024
2328
  * @private
2025
2329
  */
2026
2330
  _updateMaskListSelection(selectedMask) {
2027
- const maskListElement = document.getElementById(this.elements.maskList);
2028
- if (!maskListElement)
2029
- return;
2331
+ const maskListElement = this._getElement("maskList");
2332
+ if (!maskListElement) return;
2030
2333
  const maskItems = maskListElement.querySelectorAll(".mask-item");
2031
2334
  maskItems.forEach((item) => {
2032
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2335
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
2033
2336
  item.classList.toggle("active", isSelected);
2337
+ item.classList.toggle("selected", isSelected);
2034
2338
  });
2035
2339
  }
2036
2340
  /**
@@ -2044,22 +2348,22 @@
2044
2348
  * @public
2045
2349
  */
2046
2350
  async mergeMasks() {
2047
- if (!this.originalImage)
2048
- return;
2351
+ if (!this.originalImage) return;
2352
+ this._assertIdleForOperation("mergeMasks");
2049
2353
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2050
- if (!masks.length)
2051
- return;
2354
+ if (!masks.length) return;
2052
2355
  this.canvas.discardActiveObject();
2053
2356
  this.canvas.renderAll();
2054
2357
  try {
2055
2358
  const beforeJson = this._serializeCanvasState();
2056
2359
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2057
2360
  this.removeAllMasks({ saveHistory: false });
2058
- await this.loadImage(merged, { preserveScroll: true });
2361
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2059
2362
  const afterJson = this._serializeCanvasState();
2060
2363
  this._pushStateTransition(beforeJson, afterJson);
2061
2364
  } catch (error) {
2062
2365
  this._reportError("merge error", error);
2366
+ throw error;
2063
2367
  }
2064
2368
  }
2065
2369
  /**
@@ -2080,8 +2384,8 @@
2080
2384
  * @public
2081
2385
  */
2082
2386
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2083
- if (!this.originalImage)
2084
- return;
2387
+ if (!this.originalImage) return;
2388
+ if (!this._canMutateNow("downloadImage")) return;
2085
2389
  const exportImageArea = this.options.exportImageAreaByDefault;
2086
2390
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2087
2391
  const link = document.createElement("a");
@@ -2109,8 +2413,8 @@
2109
2413
  * @public
2110
2414
  */
2111
2415
  async exportImageBase64(options = {}) {
2112
- if (!this.originalImage)
2113
- throw new Error("No image loaded");
2416
+ if (!this.originalImage) throw new Error("No image loaded");
2417
+ this._assertIdleForOperation("exportImageBase64");
2114
2418
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2115
2419
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2116
2420
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2127,7 +2431,7 @@
2127
2431
  this.originalImage.setCoords();
2128
2432
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2129
2433
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2130
- return await this._exportCanvasRegionToDataURL({
2434
+ return this._exportCanvasRegionToDataURL({
2131
2435
  ...exportRegion,
2132
2436
  multiplier,
2133
2437
  quality,
@@ -2138,6 +2442,7 @@
2138
2442
  try {
2139
2443
  backup.object.set({ visible: backup.visible });
2140
2444
  } catch (error) {
2445
+ void error;
2141
2446
  }
2142
2447
  });
2143
2448
  this.canvas.renderAll();
@@ -2166,7 +2471,7 @@
2166
2471
  this.originalImage.setCoords();
2167
2472
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2168
2473
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2169
- finalBase64 = await this._exportCanvasRegionToDataURL({
2474
+ finalBase64 = this._exportCanvasRegionToDataURL({
2170
2475
  ...exportRegion,
2171
2476
  multiplier,
2172
2477
  quality,
@@ -2185,6 +2490,7 @@
2185
2490
  });
2186
2491
  backup.object.setCoords();
2187
2492
  } catch (error) {
2493
+ void error;
2188
2494
  }
2189
2495
  });
2190
2496
  this.canvas.renderAll();
@@ -2220,8 +2526,8 @@
2220
2526
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
2221
2527
  */
2222
2528
  async exportImageFile(options = {}) {
2223
- if (!this.originalImage)
2224
- throw new Error("No image loaded");
2529
+ if (!this.originalImage) throw new Error("No image loaded");
2530
+ this._assertIdleForOperation("exportImageFile");
2225
2531
  const {
2226
2532
  mergeMask = true,
2227
2533
  fileType = "jpeg",
@@ -2257,6 +2563,7 @@
2257
2563
  offscreenCanvas.width = imageElement.width;
2258
2564
  offscreenCanvas.height = imageElement.height;
2259
2565
  const context = offscreenCanvas.getContext("2d");
2566
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2260
2567
  context.drawImage(imageElement, 0, 0);
2261
2568
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2262
2569
  resolve(convertedDataUrl);
@@ -2285,8 +2592,7 @@
2285
2592
  }
2286
2593
  async _restoreStateAfterCropFailure(beforeJson, message, error) {
2287
2594
  this._reportError(message, error);
2288
- if (this._cropRect && this.canvas)
2289
- this._removeCropRect();
2595
+ if (this._cropRect && this.canvas) this._removeCropRect();
2290
2596
  this._cropRect = null;
2291
2597
  this._cropMode = false;
2292
2598
  if (this.canvas && this._prevSelectionSetting !== void 0) {
@@ -2301,8 +2607,7 @@
2301
2607
  }
2302
2608
  }
2303
2609
  this._updateUI();
2304
- if (this.canvas)
2305
- this.canvas.renderAll();
2610
+ if (this.canvas) this.canvas.renderAll();
2306
2611
  }
2307
2612
  _restoreCropObjectState() {
2308
2613
  if (Array.isArray(this._cropPrevEvented)) {
@@ -2314,27 +2619,31 @@
2314
2619
  visible: state.visible
2315
2620
  });
2316
2621
  } catch (error) {
2622
+ void error;
2317
2623
  }
2318
2624
  });
2319
2625
  }
2320
2626
  this._cropPrevEvented = null;
2321
2627
  }
2322
2628
  _removeCropRect() {
2323
- if (!this._cropRect)
2324
- return;
2629
+ if (!this._cropRect) return;
2325
2630
  try {
2326
2631
  if (this._cropHandlers && this._cropHandlers.length) {
2327
2632
  this._cropHandlers.forEach((targetHandlers) => {
2328
2633
  targetHandlers.handlers.forEach((handlerRecord) => {
2329
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2634
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2635
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2636
+ }
2330
2637
  });
2331
2638
  });
2332
2639
  }
2333
2640
  } catch (error) {
2641
+ void error;
2334
2642
  }
2335
2643
  try {
2336
- this.canvas.remove(this._cropRect);
2644
+ if (this.canvas) this.canvas.remove(this._cropRect);
2337
2645
  } catch (error) {
2646
+ void error;
2338
2647
  }
2339
2648
  this._cropRect = null;
2340
2649
  this._cropHandlers = [];
@@ -2349,10 +2658,10 @@
2349
2658
  * @public
2350
2659
  */
2351
2660
  enterCropMode() {
2352
- if (!this.canvas || !this.originalImage || this._cropMode)
2353
- return;
2354
- if (!this.isImageLoaded())
2355
- return;
2661
+ if (!this.canvas || !this.originalImage || this._cropMode) return;
2662
+ if (!this._canMutateNow("enterCropMode")) return;
2663
+ if (!this.isImageLoaded()) return;
2664
+ this._removeCropRect();
2356
2665
  this._cropMode = true;
2357
2666
  this._prevSelectionSetting = this.canvas.selection;
2358
2667
  this.canvas.selection = false;
@@ -2404,10 +2713,10 @@
2404
2713
  evented: false,
2405
2714
  selectable: false
2406
2715
  };
2407
- if (shouldHideMasks && (object.maskId || object.maskLabel))
2408
- updates.visible = false;
2716
+ if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
2409
2717
  object.set(updates);
2410
2718
  } catch (error) {
2719
+ void error;
2411
2720
  }
2412
2721
  }
2413
2722
  });
@@ -2421,6 +2730,7 @@
2421
2730
  cropRect.setCoords();
2422
2731
  this.canvas.requestRenderAll();
2423
2732
  } catch (error) {
2733
+ void error;
2424
2734
  }
2425
2735
  };
2426
2736
  cropRect.on("modified", handleCropRectModified);
@@ -2444,8 +2754,7 @@
2444
2754
  * @public
2445
2755
  */
2446
2756
  cancelCrop() {
2447
- if (!this.canvas || !this._cropMode)
2448
- return;
2757
+ if (!this.canvas || !this._cropMode) return;
2449
2758
  this._removeCropRect();
2450
2759
  this._restoreCropObjectState();
2451
2760
  this._cropMode = false;
@@ -2467,14 +2776,14 @@
2467
2776
  * @public
2468
2777
  */
2469
2778
  async applyCrop() {
2470
- if (!this.canvas || !this._cropMode || !this._cropRect)
2471
- return;
2779
+ if (!this.canvas || !this._cropMode || !this._cropRect) return;
2780
+ this._assertIdleForOperation("applyCrop");
2472
2781
  this._cropRect.setCoords();
2473
2782
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2474
- const cropRegion = this._getClampedCanvasRegion(rectBounds);
2783
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
2475
2784
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2476
2785
  this._restoreCropObjectState();
2477
- let beforeJson = null;
2786
+ let beforeJson;
2478
2787
  try {
2479
2788
  beforeJson = this._serializeCanvasState();
2480
2789
  } catch (error) {
@@ -2493,12 +2802,8 @@
2493
2802
  this._removeLabelForMask(mask);
2494
2803
  this.canvas.remove(mask);
2495
2804
  if (shouldPreserveMasks && intersectsCrop) {
2496
- mask.set({
2497
- left: (mask.left || 0) - cropRegion.sourceX,
2498
- top: (mask.top || 0) - cropRegion.sourceY,
2499
- visible: true
2500
- });
2501
- mask.setCoords();
2805
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2806
+ mask.set({ visible: true });
2502
2807
  preservedMasks.push(mask);
2503
2808
  }
2504
2809
  } catch (error) {
@@ -2529,7 +2834,7 @@
2529
2834
  return;
2530
2835
  }
2531
2836
  try {
2532
- await this.loadImage(croppedBase64);
2837
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2533
2838
  if (preservedMasks.length) {
2534
2839
  preservedMasks.forEach((mask) => {
2535
2840
  this._rebindMaskEvents(mask);
@@ -2545,9 +2850,9 @@
2545
2850
  await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
2546
2851
  return;
2547
2852
  }
2548
- let afterJson = null;
2853
+ let afterJson;
2549
2854
  try {
2550
- afterJson = this._serializeCanvasState();
2855
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2551
2856
  } catch (error) {
2552
2857
  this._reportWarning("applyCrop: failed to serialize after state", error);
2553
2858
  afterJson = null;
@@ -2567,9 +2872,8 @@
2567
2872
  * @private
2568
2873
  */
2569
2874
  _updateInputs() {
2570
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
2571
- if (scaleInputElement)
2572
- scaleInputElement.value = Math.round(this.currentScale * 100);
2875
+ const scaleInputElement = this._getElement("scaleRate");
2876
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2573
2877
  }
2574
2878
  /**
2575
2879
  * Updates the enabled/disabled state of various UI controls (buttons)
@@ -2577,6 +2881,7 @@
2577
2881
  * @private
2578
2882
  */
2579
2883
  _updateUI() {
2884
+ if (!this.canvas) return;
2580
2885
  const hasImage = !!this.originalImage;
2581
2886
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2582
2887
  const hasMasks = masks.length > 0;
@@ -2588,9 +2893,8 @@
2588
2893
  const isInCropMode = !!this._cropMode;
2589
2894
  if (isInCropMode) {
2590
2895
  for (const key of Object.keys(this.elements || {})) {
2591
- const element = document.getElementById(this.elements[key]);
2592
- if (!element)
2593
- continue;
2896
+ const element = this._getElement(key);
2897
+ if (!element) continue;
2594
2898
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2595
2899
  this._setDisabled(key, false);
2596
2900
  } else {
@@ -2625,9 +2929,8 @@
2625
2929
  * @private
2626
2930
  */
2627
2931
  _setDisabled(key, disabled) {
2628
- const element = document.getElementById(this.elements[key]);
2629
- if (!element)
2630
- return;
2932
+ const element = this._getElement(key);
2933
+ if (!element) return;
2631
2934
  if ("disabled" in element) {
2632
2935
  element.disabled = !!disabled;
2633
2936
  return;
@@ -2641,10 +2944,8 @@
2641
2944
  }
2642
2945
  }
2643
2946
  _isElementDisabled(element) {
2644
- if (!element)
2645
- return false;
2646
- if ("disabled" in element)
2647
- return !!element.disabled;
2947
+ if (!element) return false;
2948
+ if ("disabled" in element) return !!element.disabled;
2648
2949
  return element.getAttribute("aria-disabled") === "true";
2649
2950
  }
2650
2951
  /**
@@ -2652,8 +2953,7 @@
2652
2953
  * @private
2653
2954
  */
2654
2955
  _updatePlaceholderStatus() {
2655
- if (!this.options.showPlaceholder)
2656
- return;
2956
+ if (!this.options.showPlaceholder) return;
2657
2957
  this._setPlaceholderVisible(!this.originalImage);
2658
2958
  }
2659
2959
  /**
@@ -2663,17 +2963,57 @@
2663
2963
  * @private
2664
2964
  */
2665
2965
  _setPlaceholderVisible(show) {
2666
- if (!this.placeholderElement || !this.containerElement)
2667
- return;
2668
- if (show) {
2669
- this.placeholderElement.classList.remove("d-none");
2670
- this.placeholderElement.classList.add("d-flex");
2671
- this.containerElement.classList.add("d-none");
2966
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
2967
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
2968
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
2969
+ this._setElementVisible(canvasVisibilityElement, !show);
2970
+ }
2971
+ }
2972
+ _getCanvasVisibilityElement() {
2973
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
2974
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
2975
+ return wrapperElement || this.canvasElement;
2976
+ }
2977
+ return this.containerElement || wrapperElement || this.canvasElement;
2978
+ }
2979
+ /**
2980
+ * Updates element visibility.
2981
+ *
2982
+ * @param {HTMLElement} element - Element whose visibility should be updated.
2983
+ * @param {boolean} isVisible - If true, removes the hidden state.
2984
+ * @returns {void}
2985
+ * @private
2986
+ */
2987
+ _setElementVisible(element, isVisible) {
2988
+ if (!element) return;
2989
+ this._rememberElementVisibility(element);
2990
+ element.hidden = !isVisible;
2991
+ element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2992
+ if (element.classList) {
2993
+ element.classList.toggle("d-none", !isVisible);
2994
+ }
2995
+ }
2996
+ _rememberElementVisibility(element) {
2997
+ if (!element || this._visibilityStateByElement.has(element)) return;
2998
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
2999
+ }
3000
+ _captureElementVisibility(element) {
3001
+ if (!element) return null;
3002
+ return {
3003
+ hidden: element.hidden,
3004
+ ariaHidden: element.getAttribute("aria-hidden"),
3005
+ className: element.className
3006
+ };
3007
+ }
3008
+ _restoreElementVisibility(element, state) {
3009
+ if (!element || !state) return;
3010
+ element.hidden = !!state.hidden;
3011
+ if (state.ariaHidden === null) {
3012
+ element.removeAttribute("aria-hidden");
2672
3013
  } else {
2673
- this.placeholderElement.classList.remove("d-flex");
2674
- this.placeholderElement.classList.add("d-none");
2675
- this.containerElement.classList.remove("d-none");
3014
+ element.setAttribute("aria-hidden", state.ariaHidden);
2676
3015
  }
3016
+ element.className = state.className || "";
2677
3017
  }
2678
3018
  /**
2679
3019
  * Cleans up and disposes of the canvas and related references.
@@ -2681,44 +3021,84 @@
2681
3021
  * @public
2682
3022
  */
2683
3023
  dispose() {
3024
+ this._disposed = true;
3025
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3026
+ if (this.animationQueue) {
3027
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3028
+ }
2684
3029
  try {
2685
- for (const key in this._handlersByElementKey || {}) {
2686
- const handlers = this._handlersByElementKey[key] || [];
2687
- const element = document.getElementById(this.elements[key]);
2688
- if (!element)
2689
- continue;
3030
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3031
+ const element = this._getElement(key);
3032
+ if (!element) continue;
2690
3033
  handlers.forEach((handlerRecord) => {
2691
3034
  try {
2692
3035
  element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
2693
3036
  } catch (error) {
3037
+ void error;
2694
3038
  }
2695
3039
  });
2696
3040
  }
2697
3041
  } catch (error) {
3042
+ void error;
2698
3043
  }
2699
3044
  if (this._cropRect) {
2700
3045
  try {
2701
3046
  this.canvas.remove(this._cropRect);
2702
3047
  } catch (error) {
3048
+ void error;
2703
3049
  }
2704
3050
  this._cropRect = null;
2705
3051
  }
2706
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3052
+ if (this.containerElement && this._containerOriginalOverflow) {
3053
+ try {
3054
+ this._restoreContainerOverflowState();
3055
+ } catch (error) {
3056
+ void error;
3057
+ }
3058
+ }
3059
+ if (this._visibilityStateByElement) {
3060
+ try {
3061
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3062
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3063
+ if (state) this._restoreElementVisibility(element, state);
3064
+ });
3065
+ } catch (error) {
3066
+ void error;
3067
+ }
3068
+ }
3069
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2707
3070
  try {
2708
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3071
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3072
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3073
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2709
3074
  } catch (error) {
3075
+ void error;
2710
3076
  }
2711
3077
  }
2712
3078
  if (this.canvas) {
2713
3079
  try {
2714
3080
  this.canvas.dispose();
2715
3081
  } catch (error) {
3082
+ void error;
2716
3083
  }
2717
3084
  this.canvas = null;
2718
3085
  this.canvasElement = null;
2719
3086
  this.isImageLoadedToCanvas = false;
2720
3087
  }
2721
3088
  this._handlersByElementKey = {};
3089
+ this._elementCache = {};
3090
+ this._clearMaskPlacementMemory();
3091
+ this.originalImage = null;
3092
+ this.baseImageScale = 1;
3093
+ this.currentScale = 1;
3094
+ this.currentRotation = 0;
3095
+ this.isAnimating = false;
3096
+ this._cropMode = false;
3097
+ this._cropRect = null;
3098
+ this._cropHandlers = [];
3099
+ this._cropPrevEvented = null;
3100
+ this._prevSelectionSetting = void 0;
3101
+ this._initialized = false;
2722
3102
  }
2723
3103
  };
2724
3104
  var AnimationQueue = class {
@@ -2728,6 +3108,7 @@
2728
3108
  constructor() {
2729
3109
  this.animationTasks = [];
2730
3110
  this.isRunning = false;
3111
+ this.currentTask = null;
2731
3112
  }
2732
3113
  /**
2733
3114
  * Adds an animation function to the queue.
@@ -2737,12 +3118,29 @@
2737
3118
  */
2738
3119
  async add(animationFn) {
2739
3120
  return new Promise((resolve, reject) => {
2740
- this.animationTasks.push({ animationFn, resolve, reject });
3121
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2741
3122
  if (!this.isRunning) {
2742
3123
  this._drainQueue();
2743
3124
  }
2744
3125
  });
2745
3126
  }
3127
+ isBusy() {
3128
+ return this.isRunning || this.animationTasks.length > 0;
3129
+ }
3130
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3131
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3132
+ const tasks = [
3133
+ ...this.currentTask ? [this.currentTask] : [],
3134
+ ...this.animationTasks.splice(0)
3135
+ ];
3136
+ tasks.forEach((task) => {
3137
+ if (!task || task.isSettled) return;
3138
+ task.isSettled = true;
3139
+ task.reject(cancellationError);
3140
+ });
3141
+ this.isRunning = false;
3142
+ this.currentTask = null;
3143
+ }
2746
3144
  /**
2747
3145
  * Runs queued animation tasks sequentially until the queue is empty.
2748
3146
  *
@@ -2750,19 +3148,27 @@
2750
3148
  * @returns {Promise<void>}
2751
3149
  */
2752
3150
  async _drainQueue() {
2753
- if (this.animationTasks.length === 0) {
2754
- this.isRunning = false;
2755
- return;
2756
- }
3151
+ if (this.isRunning) return;
2757
3152
  this.isRunning = true;
2758
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2759
- try {
2760
- const result = await animationFn();
2761
- resolve(result);
2762
- } catch (error) {
2763
- reject(error);
3153
+ while (this.animationTasks.length > 0) {
3154
+ const task = this.animationTasks.shift();
3155
+ this.currentTask = task;
3156
+ try {
3157
+ const result = await task.animationFn();
3158
+ if (!task.isSettled) {
3159
+ task.isSettled = true;
3160
+ task.resolve(result);
3161
+ }
3162
+ } catch (error) {
3163
+ if (!task.isSettled) {
3164
+ task.isSettled = true;
3165
+ task.reject(error);
3166
+ }
3167
+ } finally {
3168
+ if (this.currentTask === task) this.currentTask = null;
3169
+ }
2764
3170
  }
2765
- await this._drainQueue();
3171
+ this.isRunning = false;
2766
3172
  }
2767
3173
  };
2768
3174
  var Command = class {
@@ -2793,15 +3199,8 @@
2793
3199
  * @private
2794
3200
  */
2795
3201
  enqueue(task) {
2796
- const nextTask = this.pending.then(task, task);
2797
- let pendingAfterTask;
2798
- const resetPending = () => {
2799
- if (this.pending === pendingAfterTask) {
2800
- this.pending = Promise.resolve();
2801
- }
2802
- };
2803
- pendingAfterTask = nextTask.then(resetPending, resetPending);
2804
- this.pending = pendingAfterTask;
3202
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3203
+ this.pending = nextTask.catch(() => void 0);
2805
3204
  return nextTask;
2806
3205
  }
2807
3206
  /**
@@ -2812,8 +3211,14 @@
2812
3211
  * @returns {void}
2813
3212
  */
2814
3213
  execute(command) {
2815
- command.execute();
3214
+ const result = command.execute();
3215
+ if (result && typeof result.then === "function") {
3216
+ return Promise.resolve(result).then(() => {
3217
+ this.push(command);
3218
+ });
3219
+ }
2816
3220
  this.push(command);
3221
+ return result;
2817
3222
  }
2818
3223
  /**
2819
3224
  * Pushes an already-applied command onto the history stack.
@@ -2829,9 +3234,8 @@
2829
3234
  this.history.push(command);
2830
3235
  if (this.history.length > this.maxSize) {
2831
3236
  this.history.shift();
2832
- } else {
2833
- this.currentIndex++;
2834
3237
  }
3238
+ this.currentIndex = this.history.length - 1;
2835
3239
  }
2836
3240
  /**
2837
3241
  * Checks whether an undo operation is possible.