@bensitu/image-editor 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,13 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.3.1
8
+ * @version 1.4.1
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
12
12
  */
13
13
  var fabric = null;
14
+ var INTERNAL_OPERATION_TOKEN = /* @__PURE__ */ Symbol("ImageEditorInternalOperation");
14
15
  function getGlobalScope() {
15
16
  if (typeof globalThis !== "undefined") return globalThis;
16
17
  if (typeof self !== "undefined") return self;
@@ -72,6 +73,8 @@ var ImageEditor = class {
72
73
  downsampleMaxWidth: 4e3,
73
74
  downsampleMaxHeight: 3e3,
74
75
  downsampleQuality: 0.92,
76
+ preserveSourceFormat: true,
77
+ downsampleMimeType: null,
75
78
  imageLoadTimeoutMs: 3e4,
76
79
  exportMultiplier: 1,
77
80
  exportImageAreaByDefault: true,
@@ -116,10 +119,15 @@ var ImageEditor = class {
116
119
  this.currentRotation = 0;
117
120
  this.maskCounter = 0;
118
121
  this.isAnimating = false;
122
+ this._isLoading = false;
123
+ this._activeOperationName = null;
124
+ this._activeOperationToken = null;
119
125
  this.elements = {};
120
126
  this.isImageLoadedToCanvas = false;
121
127
  this.maxHistorySize = 50;
122
128
  this._handlersByElementKey = {};
129
+ this._elementCache = {};
130
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
123
131
  this._lastMask = null;
124
132
  this._lastMaskInitialLeft = null;
125
133
  this._lastMaskInitialTop = null;
@@ -130,8 +138,14 @@ var ImageEditor = class {
130
138
  this._cropHandlers = [];
131
139
  this._cropPrevEvented = null;
132
140
  this._prevSelectionSetting = void 0;
133
- this._containerOriginalOverflow = void 0;
141
+ this._containerOriginalOverflow = null;
142
+ this._lastContainerViewportSize = null;
143
+ this._canvasElementOriginalStyle = null;
144
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
134
145
  this._scrollbarSizeCache = null;
146
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
147
+ this._disposed = false;
148
+ this._initialized = false;
135
149
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
136
150
  this.animationQueue = new AnimationQueue();
137
151
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -195,6 +209,20 @@ var ImageEditor = class {
195
209
  */
196
210
  init(idMap = {}) {
197
211
  if (!this._fabricLoaded) return;
212
+ if (this._initialized || this.canvas) this.dispose();
213
+ this._disposed = false;
214
+ this._initialized = true;
215
+ this.animationQueue = new AnimationQueue();
216
+ this.historyManager = new HistoryManager(this.maxHistorySize);
217
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
218
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
219
+ this._isLoading = false;
220
+ this._activeOperationName = null;
221
+ this._activeOperationToken = null;
222
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
223
+ this._containerOriginalOverflow = null;
224
+ this._lastContainerViewportSize = null;
225
+ this._canvasElementOriginalStyle = null;
198
226
  const defaults = {
199
227
  canvas: "fabricCanvas",
200
228
  canvasContainer: null,
@@ -222,6 +250,7 @@ var ImageEditor = class {
222
250
  cancelCropBtn: "cancelCropBtn"
223
251
  };
224
252
  this.elements = { ...defaults, ...idMap };
253
+ this._elementCache = {};
225
254
  this._initCanvas();
226
255
  this._bindEvents();
227
256
  this._updateInputs();
@@ -256,16 +285,22 @@ var ImageEditor = class {
256
285
  * @private
257
286
  */
258
287
  _initCanvas() {
259
- const canvasElement = document.getElementById(this.elements.canvas);
288
+ const canvasElement = this._getElement("canvas");
260
289
  if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
261
290
  this.canvasElement = canvasElement;
291
+ this._canvasElementOriginalStyle = {
292
+ display: canvasElement.style.display || "",
293
+ width: canvasElement.style.width || "",
294
+ height: canvasElement.style.height || "",
295
+ maxWidth: canvasElement.style.maxWidth || ""
296
+ };
262
297
  if (this.elements.canvasContainer) {
263
- const containerElement = document.getElementById(this.elements.canvasContainer);
298
+ const containerElement = this._getElement("canvasContainer");
264
299
  this.containerElement = containerElement || canvasElement.parentElement;
265
300
  } else {
266
301
  this.containerElement = canvasElement.parentElement;
267
302
  }
268
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
303
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
269
304
  let initialWidth = this.options.canvasWidth;
270
305
  let initialHeight = this.options.canvasHeight;
271
306
  if (this.containerElement) {
@@ -274,6 +309,10 @@ var ImageEditor = class {
274
309
  if (containerWidth > 0 && containerHeight > 0) {
275
310
  initialWidth = containerWidth;
276
311
  initialHeight = containerHeight;
312
+ this._lastContainerViewportSize = {
313
+ width: containerWidth,
314
+ height: containerHeight
315
+ };
277
316
  }
278
317
  }
279
318
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -298,6 +337,23 @@ var ImageEditor = class {
298
337
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
299
338
  this.canvasElement.style.display = "block";
300
339
  }
340
+ /**
341
+ * Returns a configured DOM element and caches lookups for hot UI paths.
342
+ *
343
+ * @param {string} key - Key in the configured element map.
344
+ * @returns {HTMLElement|null} The configured element, or null when missing.
345
+ * @private
346
+ */
347
+ _getElement(key) {
348
+ const id = this.elements && this.elements[key];
349
+ if (!id) return null;
350
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
351
+ return this._elementCache[key];
352
+ }
353
+ const element = document.getElementById(id);
354
+ if (this._elementCache) this._elementCache[key] = element || null;
355
+ return element || null;
356
+ }
301
357
  /**
302
358
  * Records a history entry after Fabric finishes modifying one or more masks.
303
359
  *
@@ -338,9 +394,7 @@ var ImageEditor = class {
338
394
  */
339
395
  _syncContainerOverflow(options = {}) {
340
396
  if (!this.containerElement || !this.containerElement.style) return;
341
- if (this._containerOriginalOverflow === void 0) {
342
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
343
- }
397
+ this._captureContainerOverflowState();
344
398
  const shouldPreserveScroll = options.preserveScroll === true;
345
399
  if (this.options.coverImageToCanvas) {
346
400
  this.containerElement.style.overflow = "scroll";
@@ -355,58 +409,83 @@ var ImageEditor = class {
355
409
  this.containerElement.scrollTop = 0;
356
410
  }
357
411
  } else {
358
- this.containerElement.style.overflow = this._containerOriginalOverflow;
412
+ this._restoreContainerOverflowState();
359
413
  }
360
414
  }
415
+ _captureContainerOverflowState() {
416
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
417
+ this._containerOriginalOverflow = {
418
+ overflow: this.containerElement.style.overflow || "",
419
+ overflowX: this.containerElement.style.overflowX || "",
420
+ overflowY: this.containerElement.style.overflowY || ""
421
+ };
422
+ }
423
+ _restoreContainerOverflowState() {
424
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
425
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
426
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
427
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
428
+ }
429
+ _restoreContainerOverflowSnapshot(snapshot) {
430
+ if (!this.containerElement || !this.containerElement.style || !snapshot) return;
431
+ this.containerElement.style.overflow = snapshot.overflow || "";
432
+ this.containerElement.style.overflowX = snapshot.overflowX || "";
433
+ this.containerElement.style.overflowY = snapshot.overflowY || "";
434
+ }
361
435
  /**
362
436
  * DOM / UI bindings
363
437
  * @private
364
438
  */
365
439
  _bindEvents() {
366
440
  this._bindIfExists("uploadArea", "click", () => {
367
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
441
+ const uploadAreaElement = this._getElement("uploadArea");
368
442
  if (this._isElementDisabled(uploadAreaElement)) return;
369
- document.getElementById(this.elements.imageInput)?.click();
443
+ this._getElement("imageInput")?.click();
370
444
  });
371
445
  this._bindIfExists("imageInput", "change", (event) => {
372
446
  const file = event.target.files && event.target.files[0];
373
- if (file) this._loadImageFile(file);
447
+ if (file) {
448
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
449
+ event.target.value = "";
450
+ });
451
+ }
374
452
  });
375
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
376
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
453
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
454
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
377
455
  this._bindIfExists("resetBtn", "click", () => {
378
- this.resetImageTransform();
456
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
379
457
  });
380
458
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
381
459
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
382
460
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
383
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
461
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
384
462
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
385
- this._bindIfExists("undoBtn", "click", () => this.undo());
386
- this._bindIfExists("redoBtn", "click", () => this.redo());
463
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
464
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
387
465
  this._bindIfExists("rotateLeftBtn", "click", () => {
388
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
466
+ const rotationInputElement = this._getElement("rotationLeftInput");
389
467
  let step = this.options.rotationStep;
390
468
  if (rotationInputElement) {
391
469
  const parsedStep = parseFloat(rotationInputElement.value);
392
470
  if (!isNaN(parsedStep)) step = parsedStep;
393
471
  }
394
- this.rotateImage(this.currentRotation - step);
472
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
395
473
  });
396
474
  this._bindIfExists("rotateRightBtn", "click", () => {
397
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
475
+ const rotationInputElement = this._getElement("rotationRightInput");
398
476
  let step = this.options.rotationStep;
399
477
  if (rotationInputElement) {
400
478
  const parsedStep = parseFloat(rotationInputElement.value);
401
479
  if (!isNaN(parsedStep)) step = parsedStep;
402
480
  }
403
- this.rotateImage(this.currentRotation + step);
481
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
404
482
  });
405
483
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
406
484
  this._bindIfExists("applyCropBtn", "click", () => {
407
485
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
408
486
  });
409
487
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
488
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
410
489
  }
411
490
  /**
412
491
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -417,7 +496,7 @@ var ImageEditor = class {
417
496
  * @private
418
497
  */
419
498
  _bindIfExists(key, eventName, handler) {
420
- const element = document.getElementById(this.elements[key]);
499
+ const element = this._getElement(key);
421
500
  if (element) {
422
501
  element.addEventListener(eventName, handler);
423
502
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -429,16 +508,33 @@ var ImageEditor = class {
429
508
  * Reads an image File as a data URL and loads it into the Fabric canvas.
430
509
  *
431
510
  * @param {File} file - Image file selected by the user.
511
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
432
512
  * @private
433
513
  */
434
514
  _loadImageFile(file) {
435
- if (!file || !file.type.startsWith("image/")) return;
436
- const reader = new FileReader();
437
- reader.onload = (event) => this.loadImage(event.target.result);
438
- reader.onerror = (event) => {
439
- this._reportError("Image file could not be read", event);
440
- };
441
- reader.readAsDataURL(file);
515
+ if (!this._isSupportedImageFile(file)) {
516
+ const error = new Error("Selected file is not a supported image");
517
+ this._reportError("Selected file is not a supported image", error);
518
+ return Promise.reject(error);
519
+ }
520
+ return new Promise((resolve, reject) => {
521
+ const reader = new FileReader();
522
+ reader.onload = (event) => {
523
+ this.loadImage(event.target.result).then(resolve).catch(reject);
524
+ };
525
+ reader.onerror = (event) => {
526
+ const error = new Error("Image file could not be read");
527
+ this._reportError("Image file could not be read", event);
528
+ reject(error);
529
+ };
530
+ reader.readAsDataURL(file);
531
+ });
532
+ }
533
+ _isSupportedImageFile(file) {
534
+ if (!file) return false;
535
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
536
+ const fileName = String(file.name || "");
537
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
442
538
  }
443
539
  /**
444
540
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -468,98 +564,102 @@ var ImageEditor = class {
468
564
  */
469
565
  async loadImage(imageBase64, options = {}) {
470
566
  if (!this._fabricLoaded) return;
471
- if (!this.canvas) return;
567
+ if (!this.canvas || this._disposed) return;
472
568
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
569
+ this._assertIdleForOperation("loadImage", options);
570
+ this._isLoading = true;
571
+ this._updateUI();
473
572
  this._warnOnImageLayoutOptionConflict();
474
- this._setPlaceholderVisible(false);
475
- this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
476
- const imageElement = await this._createImageElement(imageBase64);
477
- let loadSource = imageBase64;
478
- if (this.options.downsampleOnLoad) {
479
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
480
- if (shouldResize) {
481
- const ratio = Math.min(
482
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
483
- this.options.downsampleMaxHeight / imageElement.naturalHeight
484
- );
485
- const targetWidth = Math.round(imageElement.naturalWidth * ratio);
486
- const targetHeight = Math.round(imageElement.naturalHeight * ratio);
487
- loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
573
+ const transaction = this._captureLoadImageTransaction();
574
+ try {
575
+ const imageElement = await this._createImageElement(imageBase64);
576
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
577
+ let loadSource = imageBase64;
578
+ if (this.options.downsampleOnLoad) {
579
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
580
+ if (shouldResize) {
581
+ const ratio = Math.min(
582
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
583
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
584
+ );
585
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
586
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
587
+ loadSource = this._resampleImageToDataURL(
588
+ imageElement,
589
+ targetWidth,
590
+ targetHeight,
591
+ this._normalizeQuality(this.options.downsampleQuality),
592
+ imageBase64
593
+ );
594
+ }
595
+ }
596
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
597
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
598
+ this.canvas.discardActiveObject();
599
+ this._hideAllMaskLabels();
600
+ this.canvas.clear();
601
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
602
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
603
+ this._setPlaceholderVisible(false);
604
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
605
+ const imageWidth = fabricImage.width;
606
+ const imageHeight = fabricImage.height;
607
+ const viewport = this._getContainerViewportSize();
608
+ const minWidth = viewport.width;
609
+ const minHeight = viewport.height;
610
+ if (this.options.fitImageToCanvas) {
611
+ const canvasWidth = Math.max(1, minWidth - 1);
612
+ const canvasHeight = Math.max(1, minHeight - 1);
613
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
614
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
615
+ fabricImage.set({ left: 0, top: 0 });
616
+ fabricImage.scale(fitScale);
617
+ this.baseImageScale = fabricImage.scaleX || 1;
618
+ } else if (this.options.coverImageToCanvas) {
619
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
620
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
621
+ fabricImage.set({ left: 0, top: 0 });
622
+ fabricImage.scale(layout.scale);
623
+ this.baseImageScale = fabricImage.scaleX || 1;
624
+ } else if (this.options.expandCanvasToImage) {
625
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
626
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
627
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
628
+ fabricImage.set({ left: 0, top: 0 });
629
+ fabricImage.scale(1);
630
+ this.baseImageScale = 1;
631
+ } else {
632
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
633
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
634
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
635
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
636
+ fabricImage.set({ left: 0, top: 0 });
637
+ fabricImage.scale(fitScale);
638
+ this.baseImageScale = fabricImage.scaleX || 1;
639
+ }
640
+ this.originalImage = fabricImage;
641
+ this.canvas.add(fabricImage);
642
+ this.canvas.sendToBack(fabricImage);
643
+ this._clearMaskPlacementMemory();
644
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
645
+ this.currentScale = 1;
646
+ this.currentRotation = 0;
647
+ this._updateInputs();
648
+ this._updateMaskList();
649
+ this.isImageLoadedToCanvas = true;
650
+ this._updateUI();
651
+ this.canvas.renderAll();
652
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
653
+ if (typeof this.onImageLoaded === "function") {
654
+ this.onImageLoaded();
488
655
  }
656
+ } catch (error) {
657
+ await this._rollbackLoadImageTransaction(transaction);
658
+ throw error;
659
+ } finally {
660
+ this._isLoading = false;
661
+ if (!this._disposed && this.canvas) this._updateUI();
489
662
  }
490
- return new Promise((resolve, reject) => {
491
- fabric.Image.fromURL(loadSource, (fabricImage) => {
492
- try {
493
- if (!fabricImage) throw new Error("Image could not be loaded");
494
- this.canvas.discardActiveObject();
495
- this._hideAllMaskLabels();
496
- this.canvas.clear();
497
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
498
- fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
499
- const imageWidth = fabricImage.width;
500
- const imageHeight = fabricImage.height;
501
- const viewport = this._getContainerViewportSize();
502
- const minWidth = viewport.width;
503
- const minHeight = viewport.height;
504
- if (this.options.fitImageToCanvas) {
505
- const canvasWidth = Math.max(1, minWidth - 1);
506
- const canvasHeight = Math.max(1, minHeight - 1);
507
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
508
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
509
- fabricImage.set({ left: 0, top: 0 });
510
- fabricImage.scale(fitScale);
511
- this.baseImageScale = fabricImage.scaleX || 1;
512
- } else if (this.options.coverImageToCanvas) {
513
- const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
514
- this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
515
- fabricImage.set({ left: 0, top: 0 });
516
- fabricImage.scale(layout.scale);
517
- this.baseImageScale = fabricImage.scaleX || 1;
518
- } else if (this.options.expandCanvasToImage) {
519
- const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
520
- const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
521
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
522
- fabricImage.set({ left: 0, top: 0 });
523
- fabricImage.scale(1);
524
- this.baseImageScale = 1;
525
- } else {
526
- const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
527
- const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
528
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
529
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
530
- fabricImage.set({ left: 0, top: 0 });
531
- fabricImage.scale(fitScale);
532
- this.baseImageScale = fabricImage.scaleX || 1;
533
- }
534
- this.originalImage = fabricImage;
535
- this.canvas.add(fabricImage);
536
- this.canvas.sendToBack(fabricImage);
537
- this._lastMask = null;
538
- this._lastMaskInitialLeft = null;
539
- this._lastMaskInitialTop = null;
540
- this._lastMaskInitialWidth = null;
541
- this.maskCounter = 0;
542
- this.currentScale = 1;
543
- this.currentRotation = 0;
544
- this._updateInputs();
545
- this._updateMaskList();
546
- this.isImageLoadedToCanvas = true;
547
- this._updateUI();
548
- this.canvas.renderAll();
549
- try {
550
- this._lastSnapshot = this._serializeCanvasState();
551
- } catch (error) {
552
- this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
553
- }
554
- if (typeof this.onImageLoaded === "function") {
555
- this.onImageLoaded();
556
- }
557
- resolve();
558
- } catch (error) {
559
- reject(error);
560
- }
561
- }, { crossOrigin: "anonymous" });
562
- });
563
663
  }
564
664
  /**
565
665
  * Checks whether there is a loaded image on the current canvas.
@@ -604,24 +704,155 @@ var ImageEditor = class {
604
704
  imageElement.src = dataUrl;
605
705
  });
606
706
  }
707
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
708
+ return new Promise((resolve, reject) => {
709
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
710
+ let isSettled = false;
711
+ let timerId;
712
+ const settle = (callback) => {
713
+ if (isSettled) return;
714
+ isSettled = true;
715
+ clearTimeout(timerId);
716
+ callback();
717
+ };
718
+ timerId = setTimeout(() => {
719
+ settle(() => reject(new Error("Fabric image load timed out")));
720
+ }, safeTimeoutMs);
721
+ try {
722
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
723
+ settle(() => {
724
+ if (!fabricImage) {
725
+ reject(new Error("Image could not be loaded"));
726
+ return;
727
+ }
728
+ resolve(fabricImage);
729
+ });
730
+ }, { crossOrigin: "anonymous" });
731
+ } catch (error) {
732
+ settle(() => reject(error));
733
+ }
734
+ });
735
+ }
736
+ _getSafeTimeoutMs(timeoutMs) {
737
+ const safeTimeoutMs = Number(timeoutMs);
738
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
739
+ }
740
+ _captureLoadImageTransaction() {
741
+ return {
742
+ canvasState: this._serializeCanvasState(),
743
+ originalImage: this.originalImage,
744
+ baseImageScale: this.baseImageScale,
745
+ currentScale: this.currentScale,
746
+ currentRotation: this.currentRotation,
747
+ maskCounter: this.maskCounter,
748
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
749
+ lastSnapshot: this._lastSnapshot,
750
+ lastMask: this._lastMask,
751
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
752
+ lastMaskInitialTop: this._lastMaskInitialTop,
753
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
754
+ containerOverflow: this.containerElement && this.containerElement.style ? {
755
+ overflow: this.containerElement.style.overflow || "",
756
+ overflowX: this.containerElement.style.overflowX || "",
757
+ overflowY: this.containerElement.style.overflowY || ""
758
+ } : null,
759
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
760
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
761
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
762
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
763
+ };
764
+ }
765
+ async _rollbackLoadImageTransaction(transaction) {
766
+ if (!transaction || !this.canvas || this._disposed) return;
767
+ let didRestoreCanvasState = false;
768
+ try {
769
+ if (transaction.canvasState) {
770
+ await this.loadFromState(transaction.canvasState);
771
+ didRestoreCanvasState = true;
772
+ }
773
+ } catch (error) {
774
+ this._lastMask = null;
775
+ this._reportError("loadImage rollback failed", error);
776
+ }
777
+ this.baseImageScale = transaction.baseImageScale;
778
+ this.currentScale = transaction.currentScale;
779
+ this.currentRotation = transaction.currentRotation;
780
+ this.maskCounter = transaction.maskCounter;
781
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
782
+ this._lastSnapshot = transaction.lastSnapshot;
783
+ if (didRestoreCanvasState) {
784
+ this._restoreLastMaskReference(transaction.lastMask);
785
+ } else {
786
+ this._lastMask = null;
787
+ }
788
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
789
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
790
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
791
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
792
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
793
+ if (this.containerElement) {
794
+ this.containerElement.scrollLeft = transaction.scrollLeft;
795
+ this.containerElement.scrollTop = transaction.scrollTop;
796
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
797
+ }
798
+ this._updateInputs();
799
+ this._updateMaskList();
800
+ this._updateUI();
801
+ if (this.canvas) this.canvas.renderAll();
802
+ }
803
+ _restoreLastMaskReference(previousLastMask) {
804
+ if (!this.canvas) {
805
+ this._lastMask = null;
806
+ return;
807
+ }
808
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
809
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
810
+ this._lastMask = masks.find((mask) => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
811
+ if (!this._lastMask) {
812
+ this._lastMaskInitialLeft = null;
813
+ this._lastMaskInitialTop = null;
814
+ this._lastMaskInitialWidth = null;
815
+ }
816
+ }
607
817
  /**
608
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
818
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
609
819
  *
610
820
  * @param {HTMLImageElement} imageElement - The image element to resample.
611
821
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
612
822
  * @param {number} targetHeight - Target height (in pixels) for the resampled image.
613
- * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
614
- * @returns {string} A data URL representing the resampled image as JPEG.
823
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
824
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
825
+ * @returns {string} A data URL representing the resampled image.
615
826
  * @private
616
827
  */
617
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
828
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
618
829
  const offscreenCanvas = document.createElement("canvas");
619
830
  offscreenCanvas.width = targetWidth;
620
831
  offscreenCanvas.height = targetHeight;
621
832
  const context = offscreenCanvas.getContext("2d");
622
833
  if (!context) throw new Error("2D canvas context is unavailable");
623
834
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
624
- return offscreenCanvas.toDataURL("image/jpeg", quality);
835
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
836
+ }
837
+ _getDataUrlMimeType(dataUrl) {
838
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
839
+ return match ? match[1].toLowerCase() : "";
840
+ }
841
+ _getDownsampleMimeType(sourceDataUrl) {
842
+ if (this.options.downsampleMimeType) {
843
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
844
+ return `image/${requestedFormat}`;
845
+ }
846
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
847
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
848
+ return sourceMimeType;
849
+ }
850
+ return "image/jpeg";
851
+ }
852
+ _captureCanvasStateOrThrow(context) {
853
+ const snapshot = this._serializeCanvasState();
854
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
855
+ return snapshot;
625
856
  }
626
857
  /**
627
858
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -640,7 +871,6 @@ var ImageEditor = class {
640
871
  if (this.canvasElement) {
641
872
  this.canvasElement.style.width = integerWidth + "px";
642
873
  this.canvasElement.style.height = integerHeight + "px";
643
- this.canvasElement.style.maxWidth = "none";
644
874
  }
645
875
  }
646
876
  _ceilCanvasDimension(value) {
@@ -656,8 +886,13 @@ var ImageEditor = class {
656
886
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
657
887
  };
658
888
  }
659
- let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
660
- let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
889
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
890
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
891
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
892
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
893
+ if (measuredWidth > 0 && measuredHeight > 0) {
894
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
895
+ }
661
896
  if (this._hasFixedContainerScrollbars()) {
662
897
  return { width, height };
663
898
  }
@@ -862,7 +1097,11 @@ var ImageEditor = class {
862
1097
  maskStyleBackups.push(backup);
863
1098
  mask.set(stylePatch);
864
1099
  });
865
- return callback();
1100
+ const result = callback();
1101
+ if (result && typeof result.then === "function") {
1102
+ throw new Error("_withNormalizedMaskStyles callback must be synchronous");
1103
+ }
1104
+ return result;
866
1105
  } finally {
867
1106
  maskStyleBackups.forEach((backup) => {
868
1107
  try {
@@ -930,9 +1169,13 @@ var ImageEditor = class {
930
1169
  * @returns {number} A finite quality value between 0 and 1.
931
1170
  * @private
932
1171
  */
933
- _normalizeQuality(quality) {
1172
+ _normalizeQuality(quality, fallback = void 0) {
1173
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1174
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1175
+ const safeFallback = Number.isFinite(numericFallback) ? Math.max(0, Math.min(1, numericFallback)) : 0.92;
1176
+ if (quality == null) return safeFallback;
934
1177
  const numericQuality = Number(quality);
935
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1178
+ if (!Number.isFinite(numericQuality)) return safeFallback;
936
1179
  return Math.max(0, Math.min(1, numericQuality));
937
1180
  }
938
1181
  /**
@@ -983,67 +1226,66 @@ var ImageEditor = class {
983
1226
  sourceHeight: Math.max(1, endY - sourceY)
984
1227
  };
985
1228
  }
986
- /**
987
- * Crops an image data URL to a source region using an offscreen canvas.
988
- *
989
- * @param {string} dataUrl - Source image data URL.
990
- * @param {number} sourceX - Source region x coordinate.
991
- * @param {number} sourceY - Source region y coordinate.
992
- * @param {number} sourceWidth - Source region width.
993
- * @param {number} sourceHeight - Source region height.
994
- * @param {number} multiplier - Export multiplier already applied to the source data URL.
995
- * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
996
- * @param {number} [quality=0.92] - Output image quality for lossy formats.
997
- * @returns {Promise<string>} Resolves with the cropped image data URL.
998
- * @private
999
- */
1000
- async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
1001
- return new Promise((resolve, reject) => {
1002
- const imageElement = new Image();
1003
- let isSettled = false;
1004
- const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1005
- const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1006
- let timerId;
1007
- const settle = (callback) => {
1008
- if (isSettled) return;
1009
- isSettled = true;
1010
- clearTimeout(timerId);
1011
- imageElement.onload = null;
1012
- imageElement.onerror = null;
1013
- callback();
1014
- };
1015
- timerId = setTimeout(() => {
1016
- settle(() => reject(new Error("Image crop load timed out")));
1017
- try {
1018
- imageElement.src = "";
1019
- } catch (error) {
1020
- void error;
1021
- }
1022
- }, safeTimeoutMs);
1023
- imageElement.onload = () => {
1024
- try {
1025
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1026
- const scaledSourceX = Math.round(sourceX * safeMultiplier);
1027
- const scaledSourceY = Math.round(sourceY * safeMultiplier);
1028
- const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1029
- const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1030
- const offscreenCanvas = document.createElement("canvas");
1031
- offscreenCanvas.width = scaledSourceWidth;
1032
- offscreenCanvas.height = scaledSourceHeight;
1033
- const context = offscreenCanvas.getContext("2d");
1034
- if (!context) throw new Error("2D canvas context is unavailable");
1035
- context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1036
- settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1037
- } catch (error) {
1038
- settle(() => reject(error));
1039
- }
1040
- };
1041
- imageElement.onerror = (error) => settle(() => reject(error));
1042
- imageElement.src = dataUrl;
1043
- });
1229
+ _hasFractionalCanvasEdge(value) {
1230
+ const numericValue = Number(value);
1231
+ if (!Number.isFinite(numericValue)) return false;
1232
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1233
+ }
1234
+ _getPartialExportEdges(bounds) {
1235
+ if (!bounds) return null;
1236
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1237
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1238
+ if (!isAxisAligned) return null;
1239
+ return {
1240
+ left: this._hasFractionalCanvasEdge(bounds.left),
1241
+ top: this._hasFractionalCanvasEdge(bounds.top),
1242
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1243
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1244
+ };
1245
+ }
1246
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1247
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1248
+ const imageElement = await this._createImageElement(dataUrl);
1249
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1250
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1251
+ const offscreenCanvas = document.createElement("canvas");
1252
+ offscreenCanvas.width = width;
1253
+ offscreenCanvas.height = height;
1254
+ const context = offscreenCanvas.getContext("2d");
1255
+ if (!context) throw new Error("2D canvas context is unavailable");
1256
+ context.drawImage(imageElement, 0, 0, width, height);
1257
+ const imageData = context.getImageData(0, 0, width, height);
1258
+ const pixels = imageData.data;
1259
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1260
+ const index = (y * width + x) * 4;
1261
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1262
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1263
+ pixels[index] = pixels[fallbackIndex];
1264
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1265
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1266
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1267
+ }
1268
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1269
+ pixels[index + 3] = 255;
1270
+ }
1271
+ };
1272
+ if (edges.left && width > 1) {
1273
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1274
+ }
1275
+ if (edges.right && width > 1) {
1276
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1277
+ }
1278
+ if (edges.top && height > 1) {
1279
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1280
+ }
1281
+ if (edges.bottom && height > 1) {
1282
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1283
+ }
1284
+ context.putImageData(imageData, 0, 0);
1285
+ return offscreenCanvas.toDataURL("image/png");
1044
1286
  }
1045
1287
  /**
1046
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1288
+ * Exports a source region directly through Fabric's region export options.
1047
1289
  *
1048
1290
  * @param {Object} region - Canvas source region and export options.
1049
1291
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1053,17 +1295,46 @@ var ImageEditor = class {
1053
1295
  * @param {number} [region.multiplier=1] - Export multiplier.
1054
1296
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1055
1297
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1298
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1056
1299
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1057
1300
  * @private
1058
1301
  */
1059
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1302
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1060
1303
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1061
- const fullDataUrl = this.canvas.toDataURL({
1062
- format,
1304
+ const safeFormat = this._normalizeImageFormat(format);
1305
+ const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1306
+ let regionDataUrl = this.canvas.toDataURL({
1307
+ format: exportFormat,
1063
1308
  quality,
1064
- multiplier: safeMultiplier
1309
+ multiplier: safeMultiplier,
1310
+ left: sourceX,
1311
+ top: sourceY,
1312
+ width: sourceWidth,
1313
+ height: sourceHeight
1065
1314
  });
1066
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1315
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1316
+ if (safeFormat !== "jpeg") return regionDataUrl;
1317
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1318
+ }
1319
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1320
+ const imageElement = await this._createImageElement(dataUrl);
1321
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1322
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1323
+ const offscreenCanvas = document.createElement("canvas");
1324
+ offscreenCanvas.width = width;
1325
+ offscreenCanvas.height = height;
1326
+ const context = offscreenCanvas.getContext("2d");
1327
+ if (!context) throw new Error("2D canvas context is unavailable");
1328
+ context.fillStyle = this._getJpegBackgroundColor();
1329
+ context.fillRect(0, 0, width, height);
1330
+ context.drawImage(imageElement, 0, 0, width, height);
1331
+ return offscreenCanvas.toDataURL("image/jpeg", this._normalizeQuality(quality));
1332
+ }
1333
+ _getJpegBackgroundColor() {
1334
+ const backgroundColor = String(this.options.backgroundColor || "").trim();
1335
+ if (!backgroundColor || backgroundColor === "transparent") return "#ffffff";
1336
+ if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return "#ffffff";
1337
+ return backgroundColor;
1067
1338
  }
1068
1339
  /**
1069
1340
  * Gets the top-left corner coordinates of the given object.
@@ -1076,11 +1347,37 @@ var ImageEditor = class {
1076
1347
  _getObjectTopLeftPoint(fabricObject) {
1077
1348
  if (!fabricObject) return { x: 0, y: 0 };
1078
1349
  fabricObject.setCoords();
1079
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1080
- if (coords && coords.length) return coords[0];
1081
1350
  const boundingRect = fabricObject.getBoundingRect(true, true);
1082
1351
  return { x: boundingRect.left, y: boundingRect.top };
1083
1352
  }
1353
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1354
+ if (!fabricObject) return { x: 0, y: 0 };
1355
+ fabricObject.setCoords();
1356
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1357
+ if (coords && coords.length) return coords[0];
1358
+ return this._getObjectTopLeftPoint(fabricObject);
1359
+ }
1360
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1361
+ if (!fabricObject) return { x: 0, y: 0 };
1362
+ if (typeof fabricObject.getPointByOrigin === "function") {
1363
+ return fabricObject.getPointByOrigin(originX, originY);
1364
+ }
1365
+ return this._getObjectTopLeftPoint(fabricObject);
1366
+ }
1367
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1368
+ if (!fabricObject) return;
1369
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1370
+ const center = fabricObject.getCenterPoint();
1371
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1372
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1373
+ } else {
1374
+ fabricObject.set({
1375
+ left: (fabricObject.left || 0) + deltaX,
1376
+ top: (fabricObject.top || 0) + deltaY
1377
+ });
1378
+ }
1379
+ fabricObject.setCoords();
1380
+ }
1084
1381
  /**
1085
1382
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1086
1383
  *
@@ -1144,8 +1441,10 @@ var ImageEditor = class {
1144
1441
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1145
1442
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1146
1443
  try {
1147
- let requiredWidth = this.canvas.getWidth();
1148
- let requiredHeight = this.canvas.getHeight();
1444
+ const currentWidth = this.canvas.getWidth();
1445
+ const currentHeight = this.canvas.getHeight();
1446
+ let requiredWidth = currentWidth;
1447
+ let requiredHeight = currentHeight;
1149
1448
  fabricObjects.forEach((fabricObject) => {
1150
1449
  if (!fabricObject) return;
1151
1450
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
@@ -1153,11 +1452,21 @@ var ImageEditor = class {
1153
1452
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1154
1453
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1155
1454
  });
1156
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1157
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1158
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1159
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1160
- if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1455
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1456
+ let minWidth = 0;
1457
+ let minHeight = 0;
1458
+ if (shouldUseScrollSafeViewport) {
1459
+ const viewport = this._getContainerViewportSize();
1460
+ const safetyMargin = this._getScrollSafetyMargin();
1461
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1462
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1463
+ } else if (this.containerElement) {
1464
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1465
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1466
+ }
1467
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1468
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1469
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1161
1470
  this._setCanvasSizeInt(newWidth, newHeight);
1162
1471
  }
1163
1472
  } catch (error) {
@@ -1183,7 +1492,120 @@ var ImageEditor = class {
1183
1492
  * @public
1184
1493
  */
1185
1494
  scaleImage(factor, options = {}) {
1186
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1495
+ try {
1496
+ this._assertCanQueueAnimation("scaleImage", options);
1497
+ } catch (error) {
1498
+ return Promise.reject(error);
1499
+ }
1500
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1501
+ if (!this._disposed && this.canvas) this._updateUI();
1502
+ });
1503
+ }
1504
+ _getInternalOperationToken(options) {
1505
+ return options && options[INTERNAL_OPERATION_TOKEN];
1506
+ }
1507
+ _isOwnInternalOperation(options) {
1508
+ const token = this._getInternalOperationToken(options);
1509
+ return !!token && token === this._activeOperationToken;
1510
+ }
1511
+ _beginBusyOperation(operationName) {
1512
+ const token = Symbol(operationName);
1513
+ this._activeOperationName = operationName;
1514
+ this._activeOperationToken = token;
1515
+ this._updateUI();
1516
+ return token;
1517
+ }
1518
+ _endBusyOperation(token) {
1519
+ if (token && token === this._activeOperationToken) {
1520
+ this._activeOperationName = null;
1521
+ this._activeOperationToken = null;
1522
+ this._updateUI();
1523
+ }
1524
+ }
1525
+ _withInternalOperationOptions(token, options = {}) {
1526
+ return {
1527
+ ...options,
1528
+ [INTERNAL_OPERATION_TOKEN]: token
1529
+ };
1530
+ }
1531
+ _assertEditorAvailable(operationName) {
1532
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1533
+ }
1534
+ _assertIdleForOperation(operationName, options = {}) {
1535
+ this._assertEditorAvailable(operationName);
1536
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1537
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1538
+ throw new Error(`${operationName} cannot run while an animation is running`);
1539
+ }
1540
+ if (this._isLoading && !isOwnInternalOperation) {
1541
+ throw new Error(`${operationName} cannot run while an image is loading`);
1542
+ }
1543
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1544
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1545
+ }
1546
+ }
1547
+ _assertCanQueueAnimation(operationName, options = {}) {
1548
+ this._assertEditorAvailable(operationName);
1549
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1550
+ throw new Error(`${operationName} cannot run while an image is loading`);
1551
+ }
1552
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1553
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1554
+ }
1555
+ }
1556
+ _canMutateNow(operationName, options = {}) {
1557
+ try {
1558
+ this._assertIdleForOperation(operationName, options);
1559
+ return true;
1560
+ } catch (error) {
1561
+ this._reportError(`${operationName} blocked`, error);
1562
+ return false;
1563
+ }
1564
+ }
1565
+ _rejectActiveAnimations(reason) {
1566
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1567
+ this._activeAnimationRejectors.forEach((reject) => {
1568
+ try {
1569
+ reject(error);
1570
+ } catch (rejectError) {
1571
+ void rejectError;
1572
+ }
1573
+ });
1574
+ this._activeAnimationRejectors.clear();
1575
+ }
1576
+ _animateFabricProperty(fabricObject, property, value) {
1577
+ return new Promise((resolve, reject) => {
1578
+ if (this._disposed || !this.canvas || !fabricObject) {
1579
+ reject(new Error("Animation cannot start after editor disposal"));
1580
+ return;
1581
+ }
1582
+ let isSettled = false;
1583
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1584
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1585
+ let timerId;
1586
+ const settle = (callback) => {
1587
+ if (isSettled) return;
1588
+ isSettled = true;
1589
+ clearTimeout(timerId);
1590
+ this._activeAnimationRejectors.delete(reject);
1591
+ callback();
1592
+ };
1593
+ this._activeAnimationRejectors.add(reject);
1594
+ timerId = setTimeout(() => {
1595
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1596
+ }, timeoutMs);
1597
+ try {
1598
+ fabricObject.animate(property, value, {
1599
+ duration,
1600
+ onChange: () => {
1601
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1602
+ },
1603
+ onComplete: () => settle(resolve)
1604
+ });
1605
+ } catch (error) {
1606
+ settle(() => reject(error));
1607
+ }
1608
+ });
1187
1609
  }
1188
1610
  /**
1189
1611
  * Scales the original image by a given factor, with animation.
@@ -1192,32 +1614,25 @@ var ImageEditor = class {
1192
1614
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1193
1615
  * @private
1194
1616
  */
1195
- _scaleImageImpl(factor, options = {}) {
1196
- if (!this.originalImage) return Promise.resolve();
1197
- if (this.isAnimating) return Promise.resolve();
1617
+ async _scaleImageImpl(factor, options = {}) {
1618
+ if (!this.originalImage || this._disposed) return;
1619
+ if (this.isAnimating) return;
1198
1620
  const saveHistory = options.saveHistory !== false;
1199
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1200
- this.currentScale = factor;
1201
- this.isAnimating = true;
1202
- this._updateUI();
1203
- const targetScale = this.baseImageScale * factor;
1204
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1205
- this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1206
- const scaleXAnimation = new Promise((resolve) => {
1207
- this.originalImage.animate("scaleX", targetScale, {
1208
- duration: this.options.animationDuration,
1209
- onChange: this.canvas.renderAll.bind(this.canvas),
1210
- onComplete: resolve
1211
- });
1212
- });
1213
- const scaleYAnimation = new Promise((resolve) => {
1214
- this.originalImage.animate("scaleY", targetScale, {
1215
- duration: this.options.animationDuration,
1216
- onChange: this.canvas.renderAll.bind(this.canvas),
1217
- onComplete: resolve
1218
- });
1219
- });
1220
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1621
+ let didStartAnimation = false;
1622
+ try {
1623
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1624
+ this.currentScale = factor;
1625
+ this.isAnimating = true;
1626
+ didStartAnimation = true;
1627
+ this._updateUI();
1628
+ const targetScale = this.baseImageScale * factor;
1629
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1630
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1631
+ await Promise.all([
1632
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1633
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1634
+ ]);
1635
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1221
1636
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1222
1637
  this.originalImage.setCoords();
1223
1638
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1227,14 +1642,15 @@ var ImageEditor = class {
1227
1642
  this.canvas.getObjects().forEach((object) => {
1228
1643
  if (object.maskId) this._syncMaskLabel(object);
1229
1644
  });
1230
- this.isAnimating = false;
1231
1645
  this._updateInputs();
1232
- this._updateUI();
1233
1646
  if (saveHistory) this.saveState();
1234
- }).catch(() => {
1235
- this.isAnimating = false;
1236
- this._updateUI();
1237
- });
1647
+ } finally {
1648
+ if (didStartAnimation) {
1649
+ this.isAnimating = false;
1650
+ this._updateInputs();
1651
+ this._updateUI();
1652
+ }
1653
+ }
1238
1654
  }
1239
1655
  /**
1240
1656
  * Rotates the original image by a given number of degrees, with animation.
@@ -1244,7 +1660,14 @@ var ImageEditor = class {
1244
1660
  * @public
1245
1661
  */
1246
1662
  rotateImage(degrees, options = {}) {
1247
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1663
+ try {
1664
+ this._assertCanQueueAnimation("rotateImage", options);
1665
+ } catch (error) {
1666
+ return Promise.reject(error);
1667
+ }
1668
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
1669
+ if (!this._disposed && this.canvas) this._updateUI();
1670
+ });
1248
1671
  }
1249
1672
  /**
1250
1673
  * Rotates the original image by a given number of degrees, with animation.
@@ -1253,43 +1676,50 @@ var ImageEditor = class {
1253
1676
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1254
1677
  * @private
1255
1678
  */
1256
- _rotateImageImpl(degrees, options = {}) {
1257
- if (!this.originalImage) return Promise.resolve();
1258
- if (this.isAnimating) return Promise.resolve();
1259
- if (isNaN(degrees)) return Promise.resolve();
1679
+ async _rotateImageImpl(degrees, options = {}) {
1680
+ if (!this.originalImage || this._disposed) return;
1681
+ if (this.isAnimating) return;
1682
+ if (isNaN(degrees)) return;
1260
1683
  const saveHistory = options.saveHistory !== false;
1261
- this.currentRotation = degrees;
1262
- this.isAnimating = true;
1263
- this._updateUI();
1264
- const center = this.originalImage.getCenterPoint();
1265
- this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
1266
- const rotationAnimation = new Promise((resolve) => {
1267
- this.originalImage.animate("angle", degrees, {
1268
- duration: this.options.animationDuration,
1269
- onChange: this.canvas.renderAll.bind(this.canvas),
1270
- onComplete: resolve
1271
- });
1272
- });
1273
- return rotationAnimation.then(() => {
1684
+ const image = this.originalImage;
1685
+ const previousOriginX = image.originX || "left";
1686
+ const previousOriginY = image.originY || "top";
1687
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1688
+ let didStartAnimation = false;
1689
+ let didCompleteRotation = false;
1690
+ try {
1691
+ this.currentRotation = degrees;
1692
+ this.isAnimating = true;
1693
+ didStartAnimation = true;
1694
+ this._updateUI();
1695
+ const center = image.getCenterPoint();
1696
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1697
+ await this._animateFabricProperty(image, "angle", degrees);
1698
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1274
1699
  this.originalImage.set("angle", degrees);
1275
1700
  this.originalImage.setCoords();
1276
1701
  if (this._shouldResizeCanvasToContentBounds()) {
1277
1702
  this._updateCanvasSizeToImageBounds();
1278
1703
  }
1279
1704
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1280
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1705
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1281
1706
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1282
1707
  this.canvas.getObjects().forEach((object) => {
1283
1708
  if (object.maskId) this._syncMaskLabel(object);
1284
1709
  });
1285
- this.isAnimating = false;
1286
1710
  this._updateInputs();
1287
- this._updateUI();
1288
1711
  if (saveHistory) this.saveState();
1289
- }).catch(() => {
1290
- this.isAnimating = false;
1291
- this._updateUI();
1292
- });
1712
+ didCompleteRotation = true;
1713
+ } finally {
1714
+ if (!didCompleteRotation && !this._disposed && image) {
1715
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1716
+ }
1717
+ if (didStartAnimation) {
1718
+ this.isAnimating = false;
1719
+ this._updateInputs();
1720
+ this._updateUI();
1721
+ }
1722
+ }
1293
1723
  }
1294
1724
  /**
1295
1725
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1299,14 +1729,22 @@ var ImageEditor = class {
1299
1729
  */
1300
1730
  resetImageTransform() {
1301
1731
  if (!this.originalImage) return Promise.resolve();
1732
+ try {
1733
+ this._assertCanQueueAnimation("resetImageTransform");
1734
+ } catch (error) {
1735
+ return Promise.reject(error);
1736
+ }
1302
1737
  return this.animationQueue.add(async () => {
1303
- const before = this._lastSnapshot || this._serializeCanvasState();
1738
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1304
1739
  await this._scaleImageImpl(1, { saveHistory: false });
1305
1740
  await this._rotateImageImpl(0, { saveHistory: false });
1306
- const after = this._serializeCanvasState();
1741
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1307
1742
  this._pushStateTransition(before, after);
1743
+ }).finally(() => {
1744
+ if (!this._disposed && this.canvas) this._updateUI();
1308
1745
  }).catch((error) => {
1309
1746
  this._reportError("resetImageTransform() failed", error);
1747
+ throw error;
1310
1748
  });
1311
1749
  }
1312
1750
  /**
@@ -1326,13 +1764,31 @@ var ImageEditor = class {
1326
1764
  * @public
1327
1765
  */
1328
1766
  loadFromState(serializedState) {
1329
- if (!serializedState || !this.canvas) return Promise.resolve();
1330
- return new Promise((resolve) => {
1767
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1768
+ if (this._cropMode || this._cropRect) {
1769
+ this._removeCropRect();
1770
+ this._restoreCropObjectState();
1771
+ this._cropMode = false;
1772
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1773
+ this.canvas.selection = !!this._prevSelectionSetting;
1774
+ }
1775
+ this._prevSelectionSetting = void 0;
1776
+ }
1777
+ return new Promise((resolve, reject) => {
1331
1778
  try {
1332
1779
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1333
1780
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1334
- this.canvas.loadFromJSON(state, () => {
1781
+ this.canvas.loadFromJSON(state, async () => {
1335
1782
  try {
1783
+ if (this._disposed || !this.canvas) {
1784
+ reject(new Error("Editor was disposed while loading state"));
1785
+ return;
1786
+ }
1787
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1788
+ if (this._disposed || !this.canvas) {
1789
+ reject(new Error("Editor was disposed while loading state"));
1790
+ return;
1791
+ }
1336
1792
  this._hideAllMaskLabels();
1337
1793
  const canvasObjects = this.canvas.getObjects();
1338
1794
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1380,15 +1836,53 @@ var ImageEditor = class {
1380
1836
  this._updatePlaceholderStatus();
1381
1837
  this._lastSnapshot = this._serializeCanvasState();
1382
1838
  this._updateUI();
1839
+ resolve();
1383
1840
  } catch (callbackError) {
1384
1841
  this._reportError("loadFromState() failed", callbackError);
1385
- } finally {
1386
- resolve();
1842
+ reject(callbackError);
1387
1843
  }
1388
1844
  });
1389
1845
  } catch (error) {
1390
1846
  this._reportError("loadFromState() failed", error);
1391
- resolve();
1847
+ reject(error);
1848
+ }
1849
+ });
1850
+ }
1851
+ async _waitForFabricImagesReady(canvasObjects) {
1852
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1853
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1854
+ typeof object.getElement === "function" ? object.getElement() : object._element
1855
+ )));
1856
+ }
1857
+ _waitForImageElementReady(imageElement) {
1858
+ if (!imageElement) return Promise.resolve();
1859
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1860
+ return new Promise((resolve, reject) => {
1861
+ let isSettled = false;
1862
+ const timerId = setTimeout(() => {
1863
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1864
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1865
+ const settle = (callback) => {
1866
+ if (isSettled) return;
1867
+ isSettled = true;
1868
+ clearTimeout(timerId);
1869
+ if (typeof imageElement.removeEventListener === "function") {
1870
+ imageElement.removeEventListener("load", handleLoad);
1871
+ imageElement.removeEventListener("error", handleError);
1872
+ } else {
1873
+ imageElement.onload = null;
1874
+ imageElement.onerror = null;
1875
+ }
1876
+ callback();
1877
+ };
1878
+ const handleLoad = () => settle(resolve);
1879
+ const handleError = (error) => settle(() => reject(error));
1880
+ if (typeof imageElement.addEventListener === "function") {
1881
+ imageElement.addEventListener("load", handleLoad, { once: true });
1882
+ imageElement.addEventListener("error", handleError, { once: true });
1883
+ } else {
1884
+ imageElement.onload = handleLoad;
1885
+ imageElement.onerror = handleError;
1392
1886
  }
1393
1887
  });
1394
1888
  }
@@ -1403,9 +1897,8 @@ var ImageEditor = class {
1403
1897
  */
1404
1898
  saveState() {
1405
1899
  if (!this.canvas) return;
1406
- const activeObject = this.canvas.getActiveObject();
1407
1900
  try {
1408
- const after = this._serializeCanvasState();
1901
+ const after = this._captureCanvasStateOrThrow("saveState");
1409
1902
  const before = this._lastSnapshot || after;
1410
1903
  if (after === before) return;
1411
1904
  let executedOnce = false;
@@ -1424,9 +1917,6 @@ var ImageEditor = class {
1424
1917
  } catch (error) {
1425
1918
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1426
1919
  } finally {
1427
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1428
- this._handleSelectionChanged([activeObject]);
1429
- }
1430
1920
  this._updateUI();
1431
1921
  }
1432
1922
  }
@@ -1442,7 +1932,10 @@ var ImageEditor = class {
1442
1932
  * @private
1443
1933
  */
1444
1934
  _pushStateTransition(before, after) {
1445
- if (!before || !after) return;
1935
+ if (!before || !after) {
1936
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1937
+ return;
1938
+ }
1446
1939
  if (before === after) return;
1447
1940
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1448
1941
  const command = new Command(
@@ -1464,6 +1957,7 @@ var ImageEditor = class {
1464
1957
  this._updateUI();
1465
1958
  }).catch((error) => {
1466
1959
  this._reportError("undo failed", error);
1960
+ throw error;
1467
1961
  });
1468
1962
  }
1469
1963
  /**
@@ -1477,6 +1971,7 @@ var ImageEditor = class {
1477
1971
  this._updateUI();
1478
1972
  }).catch((error) => {
1479
1973
  this._reportError("redo failed", error);
1974
+ throw error;
1480
1975
  });
1481
1976
  }
1482
1977
  _rebindMaskEvents(mask) {
@@ -1498,22 +1993,17 @@ var ImageEditor = class {
1498
1993
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1499
1994
  }
1500
1995
  if (Object.keys(metadata).length) mask.set(metadata);
1501
- const normalStyle = {
1502
- stroke: mask.originalStroke || "#ccc",
1503
- strokeWidth: mask.originalStrokeWidth,
1504
- opacity: mask.originalAlpha
1505
- };
1506
- const hoverStyle = {
1507
- stroke: "#ff5500",
1508
- strokeWidth: 2,
1509
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1510
- };
1511
1996
  const mouseover = () => {
1512
- mask.set(hoverStyle);
1997
+ const opacity = Number(mask.originalAlpha);
1998
+ mask.set({
1999
+ stroke: "#ff5500",
2000
+ strokeWidth: 2,
2001
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
2002
+ });
1513
2003
  if (mask.canvas) mask.canvas.requestRenderAll();
1514
2004
  };
1515
2005
  const mouseout = () => {
1516
- mask.set(normalStyle);
2006
+ mask.set(this._getMaskNormalStyle(mask));
1517
2007
  if (mask.canvas) mask.canvas.requestRenderAll();
1518
2008
  };
1519
2009
  mask.on("mouseover", mouseover);
@@ -1550,6 +2040,7 @@ var ImageEditor = class {
1550
2040
  */
1551
2041
  createMask(config = {}) {
1552
2042
  if (!this.canvas) return null;
2043
+ if (!this._canMutateNow("createMask")) return null;
1553
2044
  const shapeType = config.shape || "rect";
1554
2045
  const maskConfig = {
1555
2046
  shape: shapeType,
@@ -1586,14 +2077,10 @@ var ImageEditor = class {
1586
2077
  };
1587
2078
  if (maskConfig.left === void 0 && this._lastMask) {
1588
2079
  const previousMask = this._lastMask;
1589
- let previousMaskRight = previousMask.left;
1590
- if (previousMask.getScaledWidth) {
1591
- previousMaskRight += previousMask.getScaledWidth();
1592
- } else if (previousMask.width) {
1593
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1594
- }
1595
- left = Math.round(previousMaskRight + maskConfig.gap);
1596
- top = previousMask.top ?? firstOffset;
2080
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2081
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2082
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2083
+ top = Math.round(previousBounds.top ?? firstOffset);
1597
2084
  } else {
1598
2085
  left = resolveValue(maskConfig.left, firstOffset, "width");
1599
2086
  top = resolveValue(maskConfig.top, firstOffset, "height");
@@ -1721,6 +2208,8 @@ var ImageEditor = class {
1721
2208
  * The associated label is also removed. UI and mask list are updated.
1722
2209
  */
1723
2210
  removeSelectedMask() {
2211
+ if (!this.canvas) return;
2212
+ if (!this._canMutateNow("removeSelectedMask")) return;
1724
2213
  const activeObject = this.canvas.getActiveObject();
1725
2214
  const selectedMasks = this._getModifiedMasks(activeObject);
1726
2215
  if (!selectedMasks.length) return;
@@ -1746,6 +2235,8 @@ var ImageEditor = class {
1746
2235
  * UI and internal mask placement memory are reset.
1747
2236
  */
1748
2237
  removeAllMasks(options = {}) {
2238
+ if (!this.canvas) return;
2239
+ if (!this._canMutateNow("removeAllMasks", options)) return;
1749
2240
  const saveHistory = options.saveHistory !== false;
1750
2241
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1751
2242
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1813,6 +2304,10 @@ var ImageEditor = class {
1813
2304
  let textObject = null;
1814
2305
  if (this.options.label && typeof this.options.label.create === "function") {
1815
2306
  textObject = this.options.label.create(mask, fabric);
2307
+ if (!textObject || typeof textObject.set !== "function") {
2308
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2309
+ textObject = null;
2310
+ }
1816
2311
  }
1817
2312
  if (!textObject) {
1818
2313
  let labelText = mask.maskName;
@@ -1880,9 +2375,10 @@ var ImageEditor = class {
1880
2375
  if (!mask) return;
1881
2376
  if (!this.options.maskLabelOnSelect) return;
1882
2377
  if (!mask.__label) return;
1883
- const coords = mask.getCoords ? mask.getCoords() : null;
1884
- if (!coords || coords.length < 4) return;
1885
- const tl = coords[0];
2378
+ if (typeof mask.setCoords === "function") mask.setCoords();
2379
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2380
+ if (!bounds) return;
2381
+ const tl = { x: bounds.left, y: bounds.top };
1886
2382
  const center = mask.getCenterPoint();
1887
2383
  const vx = center.x - tl.x;
1888
2384
  const vy = center.y - tl.y;
@@ -1960,7 +2456,7 @@ var ImageEditor = class {
1960
2456
  * @private
1961
2457
  */
1962
2458
  _updateMaskList() {
1963
- const maskListElement = document.getElementById(this.elements.maskList);
2459
+ const maskListElement = this._getElement("maskList");
1964
2460
  if (!maskListElement) return;
1965
2461
  maskListElement.innerHTML = "";
1966
2462
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -1968,13 +2464,20 @@ var ImageEditor = class {
1968
2464
  const listItemElement = document.createElement("li");
1969
2465
  listItemElement.className = "list-group-item mask-item";
1970
2466
  listItemElement.textContent = mask.maskName;
1971
- listItemElement.onclick = () => {
1972
- this.canvas.setActiveObject(mask);
1973
- this._handleSelectionChanged([mask]);
1974
- };
2467
+ listItemElement.dataset.maskId = String(mask.maskId);
1975
2468
  maskListElement.appendChild(listItemElement);
1976
2469
  });
1977
2470
  }
2471
+ _handleMaskListClick(event) {
2472
+ if (!this.canvas) return;
2473
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2474
+ if (!itemElement || !itemElement.dataset) return;
2475
+ const maskId = Number(itemElement.dataset.maskId);
2476
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2477
+ if (!mask) return;
2478
+ this.canvas.setActiveObject(mask);
2479
+ this._handleSelectionChanged([mask]);
2480
+ }
1978
2481
  /**
1979
2482
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
1980
2483
  *
@@ -1982,12 +2485,13 @@ var ImageEditor = class {
1982
2485
  * @private
1983
2486
  */
1984
2487
  _updateMaskListSelection(selectedMask) {
1985
- const maskListElement = document.getElementById(this.elements.maskList);
2488
+ const maskListElement = this._getElement("maskList");
1986
2489
  if (!maskListElement) return;
1987
2490
  const maskItems = maskListElement.querySelectorAll(".mask-item");
1988
2491
  maskItems.forEach((item) => {
1989
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2492
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
1990
2493
  item.classList.toggle("active", isSelected);
2494
+ item.classList.toggle("selected", isSelected);
1991
2495
  });
1992
2496
  }
1993
2497
  /**
@@ -2002,19 +2506,36 @@ var ImageEditor = class {
2002
2506
  */
2003
2507
  async mergeMasks() {
2004
2508
  if (!this.originalImage) return;
2509
+ this._assertIdleForOperation("mergeMasks");
2005
2510
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2006
2511
  if (!masks.length) return;
2512
+ const beforeJson = this._serializeCanvasState();
2513
+ const operationToken = this._beginBusyOperation("mergeMasks");
2007
2514
  this.canvas.discardActiveObject();
2008
2515
  this.canvas.renderAll();
2009
2516
  try {
2010
- const beforeJson = this._serializeCanvasState();
2011
- const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2012
- this.removeAllMasks({ saveHistory: false });
2013
- await this.loadImage(merged, { preserveScroll: true });
2517
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
2518
+ exportImageArea: true,
2519
+ multiplier: this.options.exportMultiplier,
2520
+ fileType: "png"
2521
+ }));
2522
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2523
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2524
+ preserveScroll: true,
2525
+ resetMaskCounter: false
2526
+ }));
2014
2527
  const afterJson = this._serializeCanvasState();
2015
2528
  this._pushStateTransition(beforeJson, afterJson);
2016
2529
  } catch (error) {
2017
2530
  this._reportError("merge error", error);
2531
+ try {
2532
+ await this.loadFromState(beforeJson);
2533
+ } catch (restoreError) {
2534
+ this._reportError("mergeMasks rollback failed", restoreError);
2535
+ }
2536
+ throw error;
2537
+ } finally {
2538
+ this._endBusyOperation(operationToken);
2018
2539
  }
2019
2540
  }
2020
2541
  /**
@@ -2036,6 +2557,7 @@ var ImageEditor = class {
2036
2557
  */
2037
2558
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2038
2559
  if (!this.originalImage) return;
2560
+ if (!this._canMutateNow("downloadImage")) return;
2039
2561
  const exportImageArea = this.options.exportImageAreaByDefault;
2040
2562
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2041
2563
  const link = document.createElement("a");
@@ -2064,6 +2586,7 @@ var ImageEditor = class {
2064
2586
  */
2065
2587
  async exportImageBase64(options = {}) {
2066
2588
  if (!this.originalImage) throw new Error("No image loaded");
2589
+ this._assertIdleForOperation("exportImageBase64", options);
2067
2590
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2068
2591
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2069
2592
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2079,12 +2602,13 @@ var ImageEditor = class {
2079
2602
  this.canvas.renderAll();
2080
2603
  this.originalImage.setCoords();
2081
2604
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2082
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2605
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2083
2606
  return await this._exportCanvasRegionToDataURL({
2084
2607
  ...exportRegion,
2085
2608
  multiplier,
2086
2609
  quality,
2087
- format
2610
+ format,
2611
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2088
2612
  });
2089
2613
  } finally {
2090
2614
  maskVisibilityBackups.forEach((backup) => {
@@ -2119,12 +2643,13 @@ var ImageEditor = class {
2119
2643
  this.canvas.renderAll();
2120
2644
  this.originalImage.setCoords();
2121
2645
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2122
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2646
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2123
2647
  finalBase64 = await this._exportCanvasRegionToDataURL({
2124
2648
  ...exportRegion,
2125
2649
  multiplier,
2126
2650
  quality,
2127
- format
2651
+ format,
2652
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2128
2653
  });
2129
2654
  } finally {
2130
2655
  maskStyleBackups.forEach((backup) => {
@@ -2176,6 +2701,7 @@ var ImageEditor = class {
2176
2701
  */
2177
2702
  async exportImageFile(options = {}) {
2178
2703
  if (!this.originalImage) throw new Error("No image loaded");
2704
+ this._assertIdleForOperation("exportImageFile");
2179
2705
  const {
2180
2706
  mergeMask = true,
2181
2707
  fileType = "jpeg",
@@ -2184,19 +2710,20 @@ var ImageEditor = class {
2184
2710
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
2185
2711
  } = options;
2186
2712
  const safeFileType = this._normalizeImageFormat(fileType);
2713
+ const normalizedQuality = this._normalizeQuality(quality);
2187
2714
  let imageBase64;
2188
2715
  if (mergeMask) {
2189
2716
  imageBase64 = await this.exportImageBase64({
2190
2717
  exportImageArea: true,
2191
2718
  multiplier,
2192
- quality,
2719
+ quality: normalizedQuality,
2193
2720
  fileType: safeFileType
2194
2721
  });
2195
2722
  } else {
2196
2723
  imageBase64 = await this.exportImageBase64({
2197
2724
  exportImageArea: false,
2198
2725
  multiplier,
2199
- quality,
2726
+ quality: normalizedQuality,
2200
2727
  fileType: safeFileType
2201
2728
  });
2202
2729
  }
@@ -2211,8 +2738,9 @@ var ImageEditor = class {
2211
2738
  offscreenCanvas.width = imageElement.width;
2212
2739
  offscreenCanvas.height = imageElement.height;
2213
2740
  const context = offscreenCanvas.getContext("2d");
2741
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2214
2742
  context.drawImage(imageElement, 0, 0);
2215
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2743
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2216
2744
  resolve(convertedDataUrl);
2217
2745
  } catch (error) {
2218
2746
  reject(error);
@@ -2278,7 +2806,9 @@ var ImageEditor = class {
2278
2806
  if (this._cropHandlers && this._cropHandlers.length) {
2279
2807
  this._cropHandlers.forEach((targetHandlers) => {
2280
2808
  targetHandlers.handlers.forEach((handlerRecord) => {
2281
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2809
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2810
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2811
+ }
2282
2812
  });
2283
2813
  });
2284
2814
  }
@@ -2286,7 +2816,7 @@ var ImageEditor = class {
2286
2816
  void error;
2287
2817
  }
2288
2818
  try {
2289
- this.canvas.remove(this._cropRect);
2819
+ if (this.canvas) this.canvas.remove(this._cropRect);
2290
2820
  } catch (error) {
2291
2821
  void error;
2292
2822
  }
@@ -2304,7 +2834,9 @@ var ImageEditor = class {
2304
2834
  */
2305
2835
  enterCropMode() {
2306
2836
  if (!this.canvas || !this.originalImage || this._cropMode) return;
2837
+ if (!this._canMutateNow("enterCropMode")) return;
2307
2838
  if (!this.isImageLoaded()) return;
2839
+ this._removeCropRect();
2308
2840
  this._cropMode = true;
2309
2841
  this._prevSelectionSetting = this.canvas.selection;
2310
2842
  this.canvas.selection = false;
@@ -2420,6 +2952,7 @@ var ImageEditor = class {
2420
2952
  */
2421
2953
  async applyCrop() {
2422
2954
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2955
+ this._assertIdleForOperation("applyCrop");
2423
2956
  this._cropRect.setCoords();
2424
2957
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2425
2958
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
@@ -2444,12 +2977,8 @@ var ImageEditor = class {
2444
2977
  this._removeLabelForMask(mask);
2445
2978
  this.canvas.remove(mask);
2446
2979
  if (shouldPreserveMasks && intersectsCrop) {
2447
- mask.set({
2448
- left: (mask.left || 0) - cropRegion.sourceX,
2449
- top: (mask.top || 0) - cropRegion.sourceY,
2450
- visible: true
2451
- });
2452
- mask.setCoords();
2980
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2981
+ mask.set({ visible: true });
2453
2982
  preservedMasks.push(mask);
2454
2983
  }
2455
2984
  } catch (error) {
@@ -2480,7 +3009,7 @@ var ImageEditor = class {
2480
3009
  return;
2481
3010
  }
2482
3011
  try {
2483
- await this.loadImage(croppedBase64);
3012
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2484
3013
  if (preservedMasks.length) {
2485
3014
  preservedMasks.forEach((mask) => {
2486
3015
  this._rebindMaskEvents(mask);
@@ -2498,7 +3027,7 @@ var ImageEditor = class {
2498
3027
  }
2499
3028
  let afterJson;
2500
3029
  try {
2501
- afterJson = this._serializeCanvasState();
3030
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2502
3031
  } catch (error) {
2503
3032
  this._reportWarning("applyCrop: failed to serialize after state", error);
2504
3033
  afterJson = null;
@@ -2518,7 +3047,7 @@ var ImageEditor = class {
2518
3047
  * @private
2519
3048
  */
2520
3049
  _updateInputs() {
2521
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
3050
+ const scaleInputElement = this._getElement("scaleRate");
2522
3051
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2523
3052
  }
2524
3053
  /**
@@ -2527,6 +3056,7 @@ var ImageEditor = class {
2527
3056
  * @private
2528
3057
  */
2529
3058
  _updateUI() {
3059
+ if (!this.canvas) return;
2530
3060
  const hasImage = !!this.originalImage;
2531
3061
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2532
3062
  const hasMasks = masks.length > 0;
@@ -2536,9 +3066,10 @@ var ImageEditor = class {
2536
3066
  const canUndo = this.historyManager?.canUndo();
2537
3067
  const canRedo = this.historyManager?.canRedo();
2538
3068
  const isInCropMode = !!this._cropMode;
3069
+ const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
2539
3070
  if (isInCropMode) {
2540
3071
  for (const key of Object.keys(this.elements || {})) {
2541
- const element = document.getElementById(this.elements[key]);
3072
+ const element = this._getElement(key);
2542
3073
  if (!element) continue;
2543
3074
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2544
3075
  this._setDisabled(key, false);
@@ -2548,23 +3079,23 @@ var ImageEditor = class {
2548
3079
  }
2549
3080
  return;
2550
3081
  }
2551
- this._setDisabled("zoomInBtn", !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2552
- this._setDisabled("zoomOutBtn", !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2553
- this._setDisabled("rotateLeftBtn", !hasImage || this.isAnimating);
2554
- this._setDisabled("rotateRightBtn", !hasImage || this.isAnimating);
2555
- this._setDisabled("addMaskBtn", !hasImage || this.isAnimating);
2556
- this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
2557
- this._setDisabled("removeAllMasksBtn", !hasMasks || this.isAnimating);
2558
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || this.isAnimating);
2559
- this._setDisabled("downloadBtn", !hasImage || this.isAnimating);
2560
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || this.isAnimating);
2561
- this._setDisabled("undoBtn", !hasImage || this.isAnimating || !canUndo);
2562
- this._setDisabled("redoBtn", !hasImage || this.isAnimating || !canRedo);
2563
- this._setDisabled("cropBtn", !hasImage || this.isAnimating);
3082
+ this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3083
+ this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3084
+ this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3085
+ this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3086
+ this._setDisabled("addMaskBtn", !hasImage || isBusy);
3087
+ this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3088
+ this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3089
+ this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3090
+ this._setDisabled("downloadBtn", !hasImage || isBusy);
3091
+ this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3092
+ this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3093
+ this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3094
+ this._setDisabled("cropBtn", !hasImage || isBusy);
2564
3095
  this._setDisabled("applyCropBtn", true);
2565
3096
  this._setDisabled("cancelCropBtn", true);
2566
- this._setDisabled("imageInput", this.isAnimating);
2567
- this._setDisabled("uploadArea", this.isAnimating);
3097
+ this._setDisabled("imageInput", isBusy);
3098
+ this._setDisabled("uploadArea", isBusy);
2568
3099
  }
2569
3100
  /**
2570
3101
  * Enables or disables a specific UI element (typically a button) by its key.
@@ -2574,18 +3105,22 @@ var ImageEditor = class {
2574
3105
  * @private
2575
3106
  */
2576
3107
  _setDisabled(key, disabled) {
2577
- const element = document.getElementById(this.elements[key]);
3108
+ const element = this._getElement(key);
2578
3109
  if (!element) return;
2579
3110
  if ("disabled" in element) {
2580
3111
  element.disabled = !!disabled;
2581
3112
  return;
2582
3113
  }
3114
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3115
+ if (!this._elementOriginalPointerEvents.has(key)) {
3116
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || "");
3117
+ }
2583
3118
  if (disabled) {
2584
3119
  element.setAttribute("aria-disabled", "true");
2585
3120
  element.style.pointerEvents = "none";
2586
3121
  } else {
2587
3122
  element.removeAttribute("aria-disabled");
2588
- element.style.pointerEvents = "";
3123
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? "";
2589
3124
  }
2590
3125
  }
2591
3126
  _isElementDisabled(element) {
@@ -2608,9 +3143,18 @@ var ImageEditor = class {
2608
3143
  * @private
2609
3144
  */
2610
3145
  _setPlaceholderVisible(show) {
2611
- if (!this.placeholderElement || !this.containerElement) return;
2612
- this._setElementVisible(this.placeholderElement, show);
2613
- this._setElementVisible(this.containerElement, !show);
3146
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
3147
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
3148
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
3149
+ this._setElementVisible(canvasVisibilityElement, !show);
3150
+ }
3151
+ }
3152
+ _getCanvasVisibilityElement() {
3153
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
3154
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
3155
+ return wrapperElement || this.canvasElement;
3156
+ }
3157
+ return this.containerElement || wrapperElement || this.canvasElement;
2614
3158
  }
2615
3159
  /**
2616
3160
  * Updates element visibility.
@@ -2622,9 +3166,34 @@ var ImageEditor = class {
2622
3166
  */
2623
3167
  _setElementVisible(element, isVisible) {
2624
3168
  if (!element) return;
3169
+ this._rememberElementVisibility(element);
2625
3170
  element.hidden = !isVisible;
2626
3171
  element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2627
- if (isVisible && element.classList) element.classList.remove("d-none");
3172
+ if (element.classList) {
3173
+ element.classList.toggle("d-none", !isVisible);
3174
+ }
3175
+ }
3176
+ _rememberElementVisibility(element) {
3177
+ if (!element || this._visibilityStateByElement.has(element)) return;
3178
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3179
+ }
3180
+ _captureElementVisibility(element) {
3181
+ if (!element) return null;
3182
+ return {
3183
+ hidden: element.hidden,
3184
+ ariaHidden: element.getAttribute("aria-hidden"),
3185
+ className: element.className
3186
+ };
3187
+ }
3188
+ _restoreElementVisibility(element, state) {
3189
+ if (!element || !state) return;
3190
+ element.hidden = !!state.hidden;
3191
+ if (state.ariaHidden === null) {
3192
+ element.removeAttribute("aria-hidden");
3193
+ } else {
3194
+ element.setAttribute("aria-hidden", state.ariaHidden);
3195
+ }
3196
+ element.className = state.className || "";
2628
3197
  }
2629
3198
  /**
2630
3199
  * Cleans up and disposes of the canvas and related references.
@@ -2632,10 +3201,17 @@ var ImageEditor = class {
2632
3201
  * @public
2633
3202
  */
2634
3203
  dispose() {
3204
+ this._disposed = true;
3205
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3206
+ if (this.animationQueue) {
3207
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3208
+ }
3209
+ this._isLoading = false;
3210
+ this._activeOperationName = null;
3211
+ this._activeOperationToken = null;
2635
3212
  try {
2636
- for (const key in this._handlersByElementKey || {}) {
2637
- const handlers = this._handlersByElementKey[key] || [];
2638
- const element = document.getElementById(this.elements[key]);
3213
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3214
+ const element = this._getElement(key);
2639
3215
  if (!element) continue;
2640
3216
  handlers.forEach((handlerRecord) => {
2641
3217
  try {
@@ -2656,9 +3232,28 @@ var ImageEditor = class {
2656
3232
  }
2657
3233
  this._cropRect = null;
2658
3234
  }
2659
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3235
+ if (this.containerElement && this._containerOriginalOverflow) {
3236
+ try {
3237
+ this._restoreContainerOverflowState();
3238
+ } catch (error) {
3239
+ void error;
3240
+ }
3241
+ }
3242
+ if (this._visibilityStateByElement) {
3243
+ try {
3244
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3245
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3246
+ if (state) this._restoreElementVisibility(element, state);
3247
+ });
3248
+ } catch (error) {
3249
+ void error;
3250
+ }
3251
+ }
3252
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2660
3253
  try {
2661
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3254
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3255
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3256
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2662
3257
  } catch (error) {
2663
3258
  void error;
2664
3259
  }
@@ -2674,6 +3269,22 @@ var ImageEditor = class {
2674
3269
  this.isImageLoadedToCanvas = false;
2675
3270
  }
2676
3271
  this._handlersByElementKey = {};
3272
+ this._elementCache = {};
3273
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3274
+ this._clearMaskPlacementMemory();
3275
+ this.originalImage = null;
3276
+ this.baseImageScale = 1;
3277
+ this.currentScale = 1;
3278
+ this.currentRotation = 0;
3279
+ this.isAnimating = false;
3280
+ this._isLoading = false;
3281
+ this._cropMode = false;
3282
+ this._cropRect = null;
3283
+ this._cropHandlers = [];
3284
+ this._cropPrevEvented = null;
3285
+ this._prevSelectionSetting = void 0;
3286
+ this._lastContainerViewportSize = null;
3287
+ this._initialized = false;
2677
3288
  }
2678
3289
  };
2679
3290
  var AnimationQueue = class {
@@ -2683,6 +3294,8 @@ var AnimationQueue = class {
2683
3294
  constructor() {
2684
3295
  this.animationTasks = [];
2685
3296
  this.isRunning = false;
3297
+ this.currentTask = null;
3298
+ this._generation = 0;
2686
3299
  }
2687
3300
  /**
2688
3301
  * Adds an animation function to the queue.
@@ -2692,12 +3305,30 @@ var AnimationQueue = class {
2692
3305
  */
2693
3306
  async add(animationFn) {
2694
3307
  return new Promise((resolve, reject) => {
2695
- this.animationTasks.push({ animationFn, resolve, reject });
3308
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2696
3309
  if (!this.isRunning) {
2697
3310
  this._drainQueue();
2698
3311
  }
2699
3312
  });
2700
3313
  }
3314
+ isBusy() {
3315
+ return this.isRunning || this.animationTasks.length > 0;
3316
+ }
3317
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3318
+ this._generation += 1;
3319
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3320
+ const tasks = [
3321
+ ...this.currentTask ? [this.currentTask] : [],
3322
+ ...this.animationTasks.splice(0)
3323
+ ];
3324
+ tasks.forEach((task) => {
3325
+ if (!task || task.isSettled) return;
3326
+ task.isSettled = true;
3327
+ task.reject(cancellationError);
3328
+ });
3329
+ this.isRunning = false;
3330
+ this.currentTask = null;
3331
+ }
2701
3332
  /**
2702
3333
  * Runs queued animation tasks sequentially until the queue is empty.
2703
3334
  *
@@ -2705,19 +3336,34 @@ var AnimationQueue = class {
2705
3336
  * @returns {Promise<void>}
2706
3337
  */
2707
3338
  async _drainQueue() {
2708
- if (this.animationTasks.length === 0) {
2709
- this.isRunning = false;
2710
- return;
2711
- }
3339
+ if (this.isRunning) return;
3340
+ const generation = this._generation;
2712
3341
  this.isRunning = true;
2713
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2714
3342
  try {
2715
- const result = await animationFn();
2716
- resolve(result);
2717
- } catch (error) {
2718
- reject(error);
3343
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3344
+ const task = this.animationTasks.shift();
3345
+ this.currentTask = task;
3346
+ try {
3347
+ const result = await task.animationFn();
3348
+ if (generation === this._generation && !task.isSettled) {
3349
+ task.isSettled = true;
3350
+ task.resolve(result);
3351
+ }
3352
+ } catch (error) {
3353
+ if (generation === this._generation && !task.isSettled) {
3354
+ task.isSettled = true;
3355
+ task.reject(error);
3356
+ }
3357
+ } finally {
3358
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3359
+ }
3360
+ }
3361
+ } finally {
3362
+ if (generation === this._generation) {
3363
+ this.isRunning = false;
3364
+ this.currentTask = null;
3365
+ }
2719
3366
  }
2720
- await this._drainQueue();
2721
3367
  }
2722
3368
  };
2723
3369
  var Command = class {
@@ -2748,15 +3394,8 @@ var HistoryManager = class {
2748
3394
  * @private
2749
3395
  */
2750
3396
  enqueue(task) {
2751
- const nextTask = this.pending.then(task, task);
2752
- let pendingAfterTask;
2753
- const resetPending = () => {
2754
- if (this.pending === pendingAfterTask) {
2755
- this.pending = Promise.resolve();
2756
- }
2757
- };
2758
- pendingAfterTask = nextTask.then(resetPending, resetPending);
2759
- this.pending = pendingAfterTask;
3397
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3398
+ this.pending = nextTask.catch(() => void 0);
2760
3399
  return nextTask;
2761
3400
  }
2762
3401
  /**
@@ -2767,8 +3406,14 @@ var HistoryManager = class {
2767
3406
  * @returns {void}
2768
3407
  */
2769
3408
  execute(command) {
2770
- command.execute();
3409
+ const result = command.execute();
3410
+ if (result && typeof result.then === "function") {
3411
+ return Promise.resolve(result).then(() => {
3412
+ this.push(command);
3413
+ });
3414
+ }
2771
3415
  this.push(command);
3416
+ return result;
2772
3417
  }
2773
3418
  /**
2774
3419
  * Pushes an already-applied command onto the history stack.
@@ -2784,9 +3429,8 @@ var HistoryManager = class {
2784
3429
  this.history.push(command);
2785
3430
  if (this.history.length > this.maxSize) {
2786
3431
  this.history.shift();
2787
- } else {
2788
- this.currentIndex++;
2789
3432
  }
3433
+ this.currentIndex = this.history.length - 1;
2790
3434
  }
2791
3435
  /**
2792
3436
  * Checks whether an undo operation is possible.