@bensitu/image-editor 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ 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.0
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -72,6 +72,8 @@ var ImageEditor = class {
72
72
  downsampleMaxWidth: 4e3,
73
73
  downsampleMaxHeight: 3e3,
74
74
  downsampleQuality: 0.92,
75
+ preserveSourceFormat: true,
76
+ downsampleMimeType: null,
75
77
  imageLoadTimeoutMs: 3e4,
76
78
  exportMultiplier: 1,
77
79
  exportImageAreaByDefault: true,
@@ -120,6 +122,7 @@ var ImageEditor = class {
120
122
  this.isImageLoadedToCanvas = false;
121
123
  this.maxHistorySize = 50;
122
124
  this._handlersByElementKey = {};
125
+ this._elementCache = {};
123
126
  this._lastMask = null;
124
127
  this._lastMaskInitialLeft = null;
125
128
  this._lastMaskInitialTop = null;
@@ -130,8 +133,14 @@ var ImageEditor = class {
130
133
  this._cropHandlers = [];
131
134
  this._cropPrevEvented = null;
132
135
  this._prevSelectionSetting = void 0;
133
- this._containerOriginalOverflow = void 0;
136
+ this._containerOriginalOverflow = null;
137
+ this._lastContainerViewportSize = null;
138
+ this._canvasElementOriginalStyle = null;
139
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
134
140
  this._scrollbarSizeCache = null;
141
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
142
+ this._disposed = false;
143
+ this._initialized = false;
135
144
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
136
145
  this.animationQueue = new AnimationQueue();
137
146
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -195,6 +204,16 @@ var ImageEditor = class {
195
204
  */
196
205
  init(idMap = {}) {
197
206
  if (!this._fabricLoaded) return;
207
+ if (this._initialized || this.canvas) this.dispose();
208
+ this._disposed = false;
209
+ this._initialized = true;
210
+ this.animationQueue = new AnimationQueue();
211
+ this.historyManager = new HistoryManager(this.maxHistorySize);
212
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
213
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
214
+ this._containerOriginalOverflow = null;
215
+ this._lastContainerViewportSize = null;
216
+ this._canvasElementOriginalStyle = null;
198
217
  const defaults = {
199
218
  canvas: "fabricCanvas",
200
219
  canvasContainer: null,
@@ -222,6 +241,7 @@ var ImageEditor = class {
222
241
  cancelCropBtn: "cancelCropBtn"
223
242
  };
224
243
  this.elements = { ...defaults, ...idMap };
244
+ this._elementCache = {};
225
245
  this._initCanvas();
226
246
  this._bindEvents();
227
247
  this._updateInputs();
@@ -256,16 +276,22 @@ var ImageEditor = class {
256
276
  * @private
257
277
  */
258
278
  _initCanvas() {
259
- const canvasElement = document.getElementById(this.elements.canvas);
279
+ const canvasElement = this._getElement("canvas");
260
280
  if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
261
281
  this.canvasElement = canvasElement;
282
+ this._canvasElementOriginalStyle = {
283
+ display: canvasElement.style.display || "",
284
+ width: canvasElement.style.width || "",
285
+ height: canvasElement.style.height || "",
286
+ maxWidth: canvasElement.style.maxWidth || ""
287
+ };
262
288
  if (this.elements.canvasContainer) {
263
- const containerElement = document.getElementById(this.elements.canvasContainer);
289
+ const containerElement = this._getElement("canvasContainer");
264
290
  this.containerElement = containerElement || canvasElement.parentElement;
265
291
  } else {
266
292
  this.containerElement = canvasElement.parentElement;
267
293
  }
268
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
294
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
269
295
  let initialWidth = this.options.canvasWidth;
270
296
  let initialHeight = this.options.canvasHeight;
271
297
  if (this.containerElement) {
@@ -274,6 +300,10 @@ var ImageEditor = class {
274
300
  if (containerWidth > 0 && containerHeight > 0) {
275
301
  initialWidth = containerWidth;
276
302
  initialHeight = containerHeight;
303
+ this._lastContainerViewportSize = {
304
+ width: containerWidth,
305
+ height: containerHeight
306
+ };
277
307
  }
278
308
  }
279
309
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -298,6 +328,23 @@ var ImageEditor = class {
298
328
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
299
329
  this.canvasElement.style.display = "block";
300
330
  }
331
+ /**
332
+ * Returns a configured DOM element and caches lookups for hot UI paths.
333
+ *
334
+ * @param {string} key - Key in the configured element map.
335
+ * @returns {HTMLElement|null} The configured element, or null when missing.
336
+ * @private
337
+ */
338
+ _getElement(key) {
339
+ const id = this.elements && this.elements[key];
340
+ if (!id) return null;
341
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
342
+ return this._elementCache[key];
343
+ }
344
+ const element = document.getElementById(id);
345
+ if (this._elementCache) this._elementCache[key] = element || null;
346
+ return element || null;
347
+ }
301
348
  /**
302
349
  * Records a history entry after Fabric finishes modifying one or more masks.
303
350
  *
@@ -338,9 +385,7 @@ var ImageEditor = class {
338
385
  */
339
386
  _syncContainerOverflow(options = {}) {
340
387
  if (!this.containerElement || !this.containerElement.style) return;
341
- if (this._containerOriginalOverflow === void 0) {
342
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
343
- }
388
+ this._captureContainerOverflowState();
344
389
  const shouldPreserveScroll = options.preserveScroll === true;
345
390
  if (this.options.coverImageToCanvas) {
346
391
  this.containerElement.style.overflow = "scroll";
@@ -355,58 +400,77 @@ var ImageEditor = class {
355
400
  this.containerElement.scrollTop = 0;
356
401
  }
357
402
  } else {
358
- this.containerElement.style.overflow = this._containerOriginalOverflow;
403
+ this._restoreContainerOverflowState();
359
404
  }
360
405
  }
406
+ _captureContainerOverflowState() {
407
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
408
+ this._containerOriginalOverflow = {
409
+ overflow: this.containerElement.style.overflow || "",
410
+ overflowX: this.containerElement.style.overflowX || "",
411
+ overflowY: this.containerElement.style.overflowY || ""
412
+ };
413
+ }
414
+ _restoreContainerOverflowState() {
415
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
416
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
417
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
418
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
419
+ }
361
420
  /**
362
421
  * DOM / UI bindings
363
422
  * @private
364
423
  */
365
424
  _bindEvents() {
366
425
  this._bindIfExists("uploadArea", "click", () => {
367
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
426
+ const uploadAreaElement = this._getElement("uploadArea");
368
427
  if (this._isElementDisabled(uploadAreaElement)) return;
369
- document.getElementById(this.elements.imageInput)?.click();
428
+ this._getElement("imageInput")?.click();
370
429
  });
371
430
  this._bindIfExists("imageInput", "change", (event) => {
372
431
  const file = event.target.files && event.target.files[0];
373
- if (file) this._loadImageFile(file);
432
+ if (file) {
433
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
434
+ event.target.value = "";
435
+ });
436
+ }
374
437
  });
375
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
376
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
438
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
439
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
377
440
  this._bindIfExists("resetBtn", "click", () => {
378
- this.resetImageTransform();
441
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
379
442
  });
380
443
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
381
444
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
382
445
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
383
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
446
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
384
447
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
385
- this._bindIfExists("undoBtn", "click", () => this.undo());
386
- this._bindIfExists("redoBtn", "click", () => this.redo());
448
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
449
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
387
450
  this._bindIfExists("rotateLeftBtn", "click", () => {
388
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
451
+ const rotationInputElement = this._getElement("rotationLeftInput");
389
452
  let step = this.options.rotationStep;
390
453
  if (rotationInputElement) {
391
454
  const parsedStep = parseFloat(rotationInputElement.value);
392
455
  if (!isNaN(parsedStep)) step = parsedStep;
393
456
  }
394
- this.rotateImage(this.currentRotation - step);
457
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
395
458
  });
396
459
  this._bindIfExists("rotateRightBtn", "click", () => {
397
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
460
+ const rotationInputElement = this._getElement("rotationRightInput");
398
461
  let step = this.options.rotationStep;
399
462
  if (rotationInputElement) {
400
463
  const parsedStep = parseFloat(rotationInputElement.value);
401
464
  if (!isNaN(parsedStep)) step = parsedStep;
402
465
  }
403
- this.rotateImage(this.currentRotation + step);
466
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
404
467
  });
405
468
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
406
469
  this._bindIfExists("applyCropBtn", "click", () => {
407
470
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
408
471
  });
409
472
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
473
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
410
474
  }
411
475
  /**
412
476
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -417,7 +481,7 @@ var ImageEditor = class {
417
481
  * @private
418
482
  */
419
483
  _bindIfExists(key, eventName, handler) {
420
- const element = document.getElementById(this.elements[key]);
484
+ const element = this._getElement(key);
421
485
  if (element) {
422
486
  element.addEventListener(eventName, handler);
423
487
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -429,16 +493,33 @@ var ImageEditor = class {
429
493
  * Reads an image File as a data URL and loads it into the Fabric canvas.
430
494
  *
431
495
  * @param {File} file - Image file selected by the user.
496
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
432
497
  * @private
433
498
  */
434
499
  _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);
500
+ if (!this._isSupportedImageFile(file)) {
501
+ const error = new Error("Selected file is not a supported image");
502
+ this._reportError("Selected file is not a supported image", error);
503
+ return Promise.reject(error);
504
+ }
505
+ return new Promise((resolve, reject) => {
506
+ const reader = new FileReader();
507
+ reader.onload = (event) => {
508
+ this.loadImage(event.target.result).then(resolve).catch(reject);
509
+ };
510
+ reader.onerror = (event) => {
511
+ const error = new Error("Image file could not be read");
512
+ this._reportError("Image file could not be read", event);
513
+ reject(error);
514
+ };
515
+ reader.readAsDataURL(file);
516
+ });
517
+ }
518
+ _isSupportedImageFile(file) {
519
+ if (!file) return false;
520
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
521
+ const fileName = String(file.name || "");
522
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
442
523
  }
443
524
  /**
444
525
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -468,98 +549,97 @@ var ImageEditor = class {
468
549
  */
469
550
  async loadImage(imageBase64, options = {}) {
470
551
  if (!this._fabricLoaded) return;
471
- if (!this.canvas) return;
552
+ if (!this.canvas || this._disposed) return;
472
553
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
554
+ this._assertIdleForOperation("loadImage");
473
555
  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);
556
+ const transaction = this._captureLoadImageTransaction();
557
+ try {
558
+ const imageElement = await this._createImageElement(imageBase64);
559
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
560
+ let loadSource = imageBase64;
561
+ if (this.options.downsampleOnLoad) {
562
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
563
+ if (shouldResize) {
564
+ const ratio = Math.min(
565
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
566
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
567
+ );
568
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
569
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
570
+ loadSource = this._resampleImageToDataURL(
571
+ imageElement,
572
+ targetWidth,
573
+ targetHeight,
574
+ this.options.downsampleQuality,
575
+ imageBase64
576
+ );
577
+ }
578
+ }
579
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
580
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
581
+ this.canvas.discardActiveObject();
582
+ this._hideAllMaskLabels();
583
+ this.canvas.clear();
584
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
585
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
586
+ this._setPlaceholderVisible(false);
587
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
588
+ const imageWidth = fabricImage.width;
589
+ const imageHeight = fabricImage.height;
590
+ const viewport = this._getContainerViewportSize();
591
+ const minWidth = viewport.width;
592
+ const minHeight = viewport.height;
593
+ if (this.options.fitImageToCanvas) {
594
+ const canvasWidth = Math.max(1, minWidth - 1);
595
+ const canvasHeight = Math.max(1, minHeight - 1);
596
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
597
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
598
+ fabricImage.set({ left: 0, top: 0 });
599
+ fabricImage.scale(fitScale);
600
+ this.baseImageScale = fabricImage.scaleX || 1;
601
+ } else if (this.options.coverImageToCanvas) {
602
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
603
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
604
+ fabricImage.set({ left: 0, top: 0 });
605
+ fabricImage.scale(layout.scale);
606
+ this.baseImageScale = fabricImage.scaleX || 1;
607
+ } else if (this.options.expandCanvasToImage) {
608
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
609
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
610
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
611
+ fabricImage.set({ left: 0, top: 0 });
612
+ fabricImage.scale(1);
613
+ this.baseImageScale = 1;
614
+ } else {
615
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
616
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
617
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
618
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
619
+ fabricImage.set({ left: 0, top: 0 });
620
+ fabricImage.scale(fitScale);
621
+ this.baseImageScale = fabricImage.scaleX || 1;
488
622
  }
623
+ this.originalImage = fabricImage;
624
+ this.canvas.add(fabricImage);
625
+ this.canvas.sendToBack(fabricImage);
626
+ this._clearMaskPlacementMemory();
627
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
628
+ this.currentScale = 1;
629
+ this.currentRotation = 0;
630
+ this._updateInputs();
631
+ this._updateMaskList();
632
+ this.isImageLoadedToCanvas = true;
633
+ this._updateUI();
634
+ this.canvas.renderAll();
635
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
636
+ if (typeof this.onImageLoaded === "function") {
637
+ this.onImageLoaded();
638
+ }
639
+ } catch (error) {
640
+ await this._rollbackLoadImageTransaction(transaction);
641
+ throw error;
489
642
  }
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
643
  }
564
644
  /**
565
645
  * Checks whether there is a loaded image on the current canvas.
@@ -604,24 +684,132 @@ var ImageEditor = class {
604
684
  imageElement.src = dataUrl;
605
685
  });
606
686
  }
687
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
688
+ return new Promise((resolve, reject) => {
689
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
690
+ let isSettled = false;
691
+ let timerId;
692
+ const settle = (callback) => {
693
+ if (isSettled) return;
694
+ isSettled = true;
695
+ clearTimeout(timerId);
696
+ callback();
697
+ };
698
+ timerId = setTimeout(() => {
699
+ settle(() => reject(new Error("Fabric image load timed out")));
700
+ }, safeTimeoutMs);
701
+ try {
702
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
703
+ settle(() => {
704
+ if (!fabricImage) {
705
+ reject(new Error("Image could not be loaded"));
706
+ return;
707
+ }
708
+ resolve(fabricImage);
709
+ });
710
+ }, { crossOrigin: "anonymous" });
711
+ } catch (error) {
712
+ settle(() => reject(error));
713
+ }
714
+ });
715
+ }
716
+ _getSafeTimeoutMs(timeoutMs) {
717
+ const safeTimeoutMs = Number(timeoutMs);
718
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
719
+ }
720
+ _captureLoadImageTransaction() {
721
+ return {
722
+ canvasState: this._serializeCanvasState(),
723
+ originalImage: this.originalImage,
724
+ baseImageScale: this.baseImageScale,
725
+ currentScale: this.currentScale,
726
+ currentRotation: this.currentRotation,
727
+ maskCounter: this.maskCounter,
728
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
729
+ lastSnapshot: this._lastSnapshot,
730
+ lastMask: this._lastMask,
731
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
732
+ lastMaskInitialTop: this._lastMaskInitialTop,
733
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
734
+ containerOverflow: this.containerElement && this.containerElement.style ? {
735
+ overflow: this.containerElement.style.overflow || "",
736
+ overflowX: this.containerElement.style.overflowX || "",
737
+ overflowY: this.containerElement.style.overflowY || ""
738
+ } : null,
739
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
740
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
741
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
742
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
743
+ };
744
+ }
745
+ async _rollbackLoadImageTransaction(transaction) {
746
+ if (!transaction || !this.canvas || this._disposed) return;
747
+ try {
748
+ if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
749
+ } catch (error) {
750
+ this._reportError("loadImage rollback failed", error);
751
+ }
752
+ this.baseImageScale = transaction.baseImageScale;
753
+ this.currentScale = transaction.currentScale;
754
+ this.currentRotation = transaction.currentRotation;
755
+ this.maskCounter = transaction.maskCounter;
756
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
757
+ this._lastSnapshot = transaction.lastSnapshot;
758
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
759
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
760
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
761
+ this._containerOriginalOverflow = transaction.containerOverflow;
762
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
763
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
764
+ if (this.containerElement) {
765
+ this.containerElement.scrollLeft = transaction.scrollLeft;
766
+ this.containerElement.scrollTop = transaction.scrollTop;
767
+ this._restoreContainerOverflowState();
768
+ }
769
+ this._updateInputs();
770
+ this._updateMaskList();
771
+ this._updateUI();
772
+ if (this.canvas) this.canvas.renderAll();
773
+ }
607
774
  /**
608
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
775
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
609
776
  *
610
777
  * @param {HTMLImageElement} imageElement - The image element to resample.
611
778
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
612
779
  * @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.
780
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
781
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
782
+ * @returns {string} A data URL representing the resampled image.
615
783
  * @private
616
784
  */
617
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
785
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
618
786
  const offscreenCanvas = document.createElement("canvas");
619
787
  offscreenCanvas.width = targetWidth;
620
788
  offscreenCanvas.height = targetHeight;
621
789
  const context = offscreenCanvas.getContext("2d");
622
790
  if (!context) throw new Error("2D canvas context is unavailable");
623
791
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
624
- return offscreenCanvas.toDataURL("image/jpeg", quality);
792
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
793
+ }
794
+ _getDataUrlMimeType(dataUrl) {
795
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
796
+ return match ? match[1].toLowerCase() : "";
797
+ }
798
+ _getDownsampleMimeType(sourceDataUrl) {
799
+ if (this.options.downsampleMimeType) {
800
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
801
+ return `image/${requestedFormat}`;
802
+ }
803
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
804
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
805
+ return sourceMimeType;
806
+ }
807
+ return "image/jpeg";
808
+ }
809
+ _captureCanvasStateOrThrow(context) {
810
+ const snapshot = this._serializeCanvasState();
811
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
812
+ return snapshot;
625
813
  }
626
814
  /**
627
815
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -640,7 +828,6 @@ var ImageEditor = class {
640
828
  if (this.canvasElement) {
641
829
  this.canvasElement.style.width = integerWidth + "px";
642
830
  this.canvasElement.style.height = integerHeight + "px";
643
- this.canvasElement.style.maxWidth = "none";
644
831
  }
645
832
  }
646
833
  _ceilCanvasDimension(value) {
@@ -656,8 +843,13 @@ var ImageEditor = class {
656
843
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
657
844
  };
658
845
  }
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));
846
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
847
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
848
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
849
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
850
+ if (measuredWidth > 0 && measuredHeight > 0) {
851
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
852
+ }
661
853
  if (this._hasFixedContainerScrollbars()) {
662
854
  return { width, height };
663
855
  }
@@ -1043,7 +1235,7 @@ var ImageEditor = class {
1043
1235
  });
1044
1236
  }
1045
1237
  /**
1046
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1238
+ * Exports a source region directly through Fabric's region export options.
1047
1239
  *
1048
1240
  * @param {Object} region - Canvas source region and export options.
1049
1241
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1056,14 +1248,17 @@ var ImageEditor = class {
1056
1248
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1057
1249
  * @private
1058
1250
  */
1059
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1251
+ _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1060
1252
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1061
- const fullDataUrl = this.canvas.toDataURL({
1253
+ return this.canvas.toDataURL({
1062
1254
  format,
1063
1255
  quality,
1064
- multiplier: safeMultiplier
1256
+ multiplier: safeMultiplier,
1257
+ left: sourceX,
1258
+ top: sourceY,
1259
+ width: sourceWidth,
1260
+ height: sourceHeight
1065
1261
  });
1066
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1067
1262
  }
1068
1263
  /**
1069
1264
  * Gets the top-left corner coordinates of the given object.
@@ -1076,11 +1271,37 @@ var ImageEditor = class {
1076
1271
  _getObjectTopLeftPoint(fabricObject) {
1077
1272
  if (!fabricObject) return { x: 0, y: 0 };
1078
1273
  fabricObject.setCoords();
1079
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1080
- if (coords && coords.length) return coords[0];
1081
1274
  const boundingRect = fabricObject.getBoundingRect(true, true);
1082
1275
  return { x: boundingRect.left, y: boundingRect.top };
1083
1276
  }
1277
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1278
+ if (!fabricObject) return { x: 0, y: 0 };
1279
+ fabricObject.setCoords();
1280
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1281
+ if (coords && coords.length) return coords[0];
1282
+ return this._getObjectTopLeftPoint(fabricObject);
1283
+ }
1284
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1285
+ if (!fabricObject) return { x: 0, y: 0 };
1286
+ if (typeof fabricObject.getPointByOrigin === "function") {
1287
+ return fabricObject.getPointByOrigin(originX, originY);
1288
+ }
1289
+ return this._getObjectTopLeftPoint(fabricObject);
1290
+ }
1291
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1292
+ if (!fabricObject) return;
1293
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1294
+ const center = fabricObject.getCenterPoint();
1295
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1296
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1297
+ } else {
1298
+ fabricObject.set({
1299
+ left: (fabricObject.left || 0) + deltaX,
1300
+ top: (fabricObject.top || 0) + deltaY
1301
+ });
1302
+ }
1303
+ fabricObject.setCoords();
1304
+ }
1084
1305
  /**
1085
1306
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1086
1307
  *
@@ -1144,8 +1365,10 @@ var ImageEditor = class {
1144
1365
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1145
1366
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1146
1367
  try {
1147
- let requiredWidth = this.canvas.getWidth();
1148
- let requiredHeight = this.canvas.getHeight();
1368
+ const currentWidth = this.canvas.getWidth();
1369
+ const currentHeight = this.canvas.getHeight();
1370
+ let requiredWidth = currentWidth;
1371
+ let requiredHeight = currentHeight;
1149
1372
  fabricObjects.forEach((fabricObject) => {
1150
1373
  if (!fabricObject) return;
1151
1374
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
@@ -1153,11 +1376,21 @@ var ImageEditor = class {
1153
1376
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1154
1377
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1155
1378
  });
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()) {
1379
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1380
+ let minWidth = 0;
1381
+ let minHeight = 0;
1382
+ if (shouldUseScrollSafeViewport) {
1383
+ const viewport = this._getContainerViewportSize();
1384
+ const safetyMargin = this._getScrollSafetyMargin();
1385
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1386
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1387
+ } else if (this.containerElement) {
1388
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1389
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1390
+ }
1391
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1392
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1393
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1161
1394
  this._setCanvasSizeInt(newWidth, newHeight);
1162
1395
  }
1163
1396
  } catch (error) {
@@ -1185,6 +1418,66 @@ var ImageEditor = class {
1185
1418
  scaleImage(factor, options = {}) {
1186
1419
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1187
1420
  }
1421
+ _assertIdleForOperation(operationName) {
1422
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1423
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1424
+ throw new Error(`${operationName} cannot run while an animation is running`);
1425
+ }
1426
+ }
1427
+ _canMutateNow(operationName) {
1428
+ try {
1429
+ this._assertIdleForOperation(operationName);
1430
+ return true;
1431
+ } catch (error) {
1432
+ this._reportError(`${operationName} blocked`, error);
1433
+ return false;
1434
+ }
1435
+ }
1436
+ _rejectActiveAnimations(reason) {
1437
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1438
+ this._activeAnimationRejectors.forEach((reject) => {
1439
+ try {
1440
+ reject(error);
1441
+ } catch (rejectError) {
1442
+ void rejectError;
1443
+ }
1444
+ });
1445
+ this._activeAnimationRejectors.clear();
1446
+ }
1447
+ _animateFabricProperty(fabricObject, property, value) {
1448
+ return new Promise((resolve, reject) => {
1449
+ if (this._disposed || !this.canvas || !fabricObject) {
1450
+ reject(new Error("Animation cannot start after editor disposal"));
1451
+ return;
1452
+ }
1453
+ let isSettled = false;
1454
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1455
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1456
+ let timerId;
1457
+ const settle = (callback) => {
1458
+ if (isSettled) return;
1459
+ isSettled = true;
1460
+ clearTimeout(timerId);
1461
+ this._activeAnimationRejectors.delete(reject);
1462
+ callback();
1463
+ };
1464
+ this._activeAnimationRejectors.add(reject);
1465
+ timerId = setTimeout(() => {
1466
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1467
+ }, timeoutMs);
1468
+ try {
1469
+ fabricObject.animate(property, value, {
1470
+ duration,
1471
+ onChange: () => {
1472
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1473
+ },
1474
+ onComplete: () => settle(resolve)
1475
+ });
1476
+ } catch (error) {
1477
+ settle(() => reject(error));
1478
+ }
1479
+ });
1480
+ }
1188
1481
  /**
1189
1482
  * Scales the original image by a given factor, with animation.
1190
1483
  * Returns a promise that resolves when the scale animation is complete.
@@ -1192,32 +1485,25 @@ var ImageEditor = class {
1192
1485
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1193
1486
  * @private
1194
1487
  */
1195
- _scaleImageImpl(factor, options = {}) {
1196
- if (!this.originalImage) return Promise.resolve();
1197
- if (this.isAnimating) return Promise.resolve();
1488
+ async _scaleImageImpl(factor, options = {}) {
1489
+ if (!this.originalImage || this._disposed) return;
1490
+ if (this.isAnimating) return;
1198
1491
  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(() => {
1492
+ let didStartAnimation = false;
1493
+ try {
1494
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1495
+ this.currentScale = factor;
1496
+ this.isAnimating = true;
1497
+ didStartAnimation = true;
1498
+ this._updateUI();
1499
+ const targetScale = this.baseImageScale * factor;
1500
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1501
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1502
+ await Promise.all([
1503
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1504
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1505
+ ]);
1506
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1221
1507
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1222
1508
  this.originalImage.setCoords();
1223
1509
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1227,14 +1513,15 @@ var ImageEditor = class {
1227
1513
  this.canvas.getObjects().forEach((object) => {
1228
1514
  if (object.maskId) this._syncMaskLabel(object);
1229
1515
  });
1230
- this.isAnimating = false;
1231
1516
  this._updateInputs();
1232
- this._updateUI();
1233
1517
  if (saveHistory) this.saveState();
1234
- }).catch(() => {
1235
- this.isAnimating = false;
1236
- this._updateUI();
1237
- });
1518
+ } finally {
1519
+ if (didStartAnimation) {
1520
+ this.isAnimating = false;
1521
+ this._updateInputs();
1522
+ this._updateUI();
1523
+ }
1524
+ }
1238
1525
  }
1239
1526
  /**
1240
1527
  * Rotates the original image by a given number of degrees, with animation.
@@ -1253,43 +1540,50 @@ var ImageEditor = class {
1253
1540
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1254
1541
  * @private
1255
1542
  */
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();
1543
+ async _rotateImageImpl(degrees, options = {}) {
1544
+ if (!this.originalImage || this._disposed) return;
1545
+ if (this.isAnimating) return;
1546
+ if (isNaN(degrees)) return;
1260
1547
  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(() => {
1548
+ const image = this.originalImage;
1549
+ const previousOriginX = image.originX || "left";
1550
+ const previousOriginY = image.originY || "top";
1551
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1552
+ let didStartAnimation = false;
1553
+ let didCompleteRotation = false;
1554
+ try {
1555
+ this.currentRotation = degrees;
1556
+ this.isAnimating = true;
1557
+ didStartAnimation = true;
1558
+ this._updateUI();
1559
+ const center = image.getCenterPoint();
1560
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1561
+ await this._animateFabricProperty(image, "angle", degrees);
1562
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1274
1563
  this.originalImage.set("angle", degrees);
1275
1564
  this.originalImage.setCoords();
1276
1565
  if (this._shouldResizeCanvasToContentBounds()) {
1277
1566
  this._updateCanvasSizeToImageBounds();
1278
1567
  }
1279
1568
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1280
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1569
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1281
1570
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1282
1571
  this.canvas.getObjects().forEach((object) => {
1283
1572
  if (object.maskId) this._syncMaskLabel(object);
1284
1573
  });
1285
- this.isAnimating = false;
1286
1574
  this._updateInputs();
1287
- this._updateUI();
1288
1575
  if (saveHistory) this.saveState();
1289
- }).catch(() => {
1290
- this.isAnimating = false;
1291
- this._updateUI();
1292
- });
1576
+ didCompleteRotation = true;
1577
+ } finally {
1578
+ if (!didCompleteRotation && !this._disposed && image) {
1579
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1580
+ }
1581
+ if (didStartAnimation) {
1582
+ this.isAnimating = false;
1583
+ this._updateInputs();
1584
+ this._updateUI();
1585
+ }
1586
+ }
1293
1587
  }
1294
1588
  /**
1295
1589
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1300,13 +1594,14 @@ var ImageEditor = class {
1300
1594
  resetImageTransform() {
1301
1595
  if (!this.originalImage) return Promise.resolve();
1302
1596
  return this.animationQueue.add(async () => {
1303
- const before = this._lastSnapshot || this._serializeCanvasState();
1597
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1304
1598
  await this._scaleImageImpl(1, { saveHistory: false });
1305
1599
  await this._rotateImageImpl(0, { saveHistory: false });
1306
- const after = this._serializeCanvasState();
1600
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1307
1601
  this._pushStateTransition(before, after);
1308
1602
  }).catch((error) => {
1309
1603
  this._reportError("resetImageTransform() failed", error);
1604
+ throw error;
1310
1605
  });
1311
1606
  }
1312
1607
  /**
@@ -1326,13 +1621,31 @@ var ImageEditor = class {
1326
1621
  * @public
1327
1622
  */
1328
1623
  loadFromState(serializedState) {
1329
- if (!serializedState || !this.canvas) return Promise.resolve();
1330
- return new Promise((resolve) => {
1624
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1625
+ if (this._cropMode || this._cropRect) {
1626
+ this._removeCropRect();
1627
+ this._restoreCropObjectState();
1628
+ this._cropMode = false;
1629
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1630
+ this.canvas.selection = !!this._prevSelectionSetting;
1631
+ }
1632
+ this._prevSelectionSetting = void 0;
1633
+ }
1634
+ return new Promise((resolve, reject) => {
1331
1635
  try {
1332
1636
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1333
1637
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1334
- this.canvas.loadFromJSON(state, () => {
1638
+ this.canvas.loadFromJSON(state, async () => {
1335
1639
  try {
1640
+ if (this._disposed || !this.canvas) {
1641
+ reject(new Error("Editor was disposed while loading state"));
1642
+ return;
1643
+ }
1644
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1645
+ if (this._disposed || !this.canvas) {
1646
+ reject(new Error("Editor was disposed while loading state"));
1647
+ return;
1648
+ }
1336
1649
  this._hideAllMaskLabels();
1337
1650
  const canvasObjects = this.canvas.getObjects();
1338
1651
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1380,18 +1693,44 @@ var ImageEditor = class {
1380
1693
  this._updatePlaceholderStatus();
1381
1694
  this._lastSnapshot = this._serializeCanvasState();
1382
1695
  this._updateUI();
1696
+ resolve();
1383
1697
  } catch (callbackError) {
1384
1698
  this._reportError("loadFromState() failed", callbackError);
1385
- } finally {
1386
- resolve();
1699
+ reject(callbackError);
1387
1700
  }
1388
1701
  });
1389
1702
  } catch (error) {
1390
1703
  this._reportError("loadFromState() failed", error);
1391
- resolve();
1704
+ reject(error);
1392
1705
  }
1393
1706
  });
1394
1707
  }
1708
+ async _waitForFabricImagesReady(canvasObjects) {
1709
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1710
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1711
+ typeof object.getElement === "function" ? object.getElement() : object._element
1712
+ )));
1713
+ }
1714
+ _waitForImageElementReady(imageElement) {
1715
+ if (!imageElement) return Promise.resolve();
1716
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1717
+ return new Promise((resolve, reject) => {
1718
+ let isSettled = false;
1719
+ const timerId = setTimeout(() => {
1720
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1721
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1722
+ const settle = (callback) => {
1723
+ if (isSettled) return;
1724
+ isSettled = true;
1725
+ clearTimeout(timerId);
1726
+ imageElement.onload = null;
1727
+ imageElement.onerror = null;
1728
+ callback();
1729
+ };
1730
+ imageElement.onload = () => settle(resolve);
1731
+ imageElement.onerror = (error) => settle(() => reject(error));
1732
+ });
1733
+ }
1395
1734
  /**
1396
1735
  * Saves the current editable canvas state as an undoable history transition.
1397
1736
  *
@@ -1403,9 +1742,8 @@ var ImageEditor = class {
1403
1742
  */
1404
1743
  saveState() {
1405
1744
  if (!this.canvas) return;
1406
- const activeObject = this.canvas.getActiveObject();
1407
1745
  try {
1408
- const after = this._serializeCanvasState();
1746
+ const after = this._captureCanvasStateOrThrow("saveState");
1409
1747
  const before = this._lastSnapshot || after;
1410
1748
  if (after === before) return;
1411
1749
  let executedOnce = false;
@@ -1424,9 +1762,6 @@ var ImageEditor = class {
1424
1762
  } catch (error) {
1425
1763
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1426
1764
  } finally {
1427
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1428
- this._handleSelectionChanged([activeObject]);
1429
- }
1430
1765
  this._updateUI();
1431
1766
  }
1432
1767
  }
@@ -1442,7 +1777,10 @@ var ImageEditor = class {
1442
1777
  * @private
1443
1778
  */
1444
1779
  _pushStateTransition(before, after) {
1445
- if (!before || !after) return;
1780
+ if (!before || !after) {
1781
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1782
+ return;
1783
+ }
1446
1784
  if (before === after) return;
1447
1785
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1448
1786
  const command = new Command(
@@ -1464,6 +1802,7 @@ var ImageEditor = class {
1464
1802
  this._updateUI();
1465
1803
  }).catch((error) => {
1466
1804
  this._reportError("undo failed", error);
1805
+ throw error;
1467
1806
  });
1468
1807
  }
1469
1808
  /**
@@ -1477,6 +1816,7 @@ var ImageEditor = class {
1477
1816
  this._updateUI();
1478
1817
  }).catch((error) => {
1479
1818
  this._reportError("redo failed", error);
1819
+ throw error;
1480
1820
  });
1481
1821
  }
1482
1822
  _rebindMaskEvents(mask) {
@@ -1498,22 +1838,17 @@ var ImageEditor = class {
1498
1838
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1499
1839
  }
1500
1840
  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
1841
  const mouseover = () => {
1512
- mask.set(hoverStyle);
1842
+ const opacity = Number(mask.originalAlpha);
1843
+ mask.set({
1844
+ stroke: "#ff5500",
1845
+ strokeWidth: 2,
1846
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
1847
+ });
1513
1848
  if (mask.canvas) mask.canvas.requestRenderAll();
1514
1849
  };
1515
1850
  const mouseout = () => {
1516
- mask.set(normalStyle);
1851
+ mask.set(this._getMaskNormalStyle(mask));
1517
1852
  if (mask.canvas) mask.canvas.requestRenderAll();
1518
1853
  };
1519
1854
  mask.on("mouseover", mouseover);
@@ -1550,6 +1885,7 @@ var ImageEditor = class {
1550
1885
  */
1551
1886
  createMask(config = {}) {
1552
1887
  if (!this.canvas) return null;
1888
+ if (!this._canMutateNow("createMask")) return null;
1553
1889
  const shapeType = config.shape || "rect";
1554
1890
  const maskConfig = {
1555
1891
  shape: shapeType,
@@ -1586,14 +1922,10 @@ var ImageEditor = class {
1586
1922
  };
1587
1923
  if (maskConfig.left === void 0 && this._lastMask) {
1588
1924
  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;
1925
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
1926
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
1927
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
1928
+ top = Math.round(previousBounds.top ?? firstOffset);
1597
1929
  } else {
1598
1930
  left = resolveValue(maskConfig.left, firstOffset, "width");
1599
1931
  top = resolveValue(maskConfig.top, firstOffset, "height");
@@ -1721,6 +2053,8 @@ var ImageEditor = class {
1721
2053
  * The associated label is also removed. UI and mask list are updated.
1722
2054
  */
1723
2055
  removeSelectedMask() {
2056
+ if (!this.canvas) return;
2057
+ if (!this._canMutateNow("removeSelectedMask")) return;
1724
2058
  const activeObject = this.canvas.getActiveObject();
1725
2059
  const selectedMasks = this._getModifiedMasks(activeObject);
1726
2060
  if (!selectedMasks.length) return;
@@ -1746,6 +2080,8 @@ var ImageEditor = class {
1746
2080
  * UI and internal mask placement memory are reset.
1747
2081
  */
1748
2082
  removeAllMasks(options = {}) {
2083
+ if (!this.canvas) return;
2084
+ if (!this._canMutateNow("removeAllMasks")) return;
1749
2085
  const saveHistory = options.saveHistory !== false;
1750
2086
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1751
2087
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1813,6 +2149,10 @@ var ImageEditor = class {
1813
2149
  let textObject = null;
1814
2150
  if (this.options.label && typeof this.options.label.create === "function") {
1815
2151
  textObject = this.options.label.create(mask, fabric);
2152
+ if (!textObject || typeof textObject.set !== "function") {
2153
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2154
+ textObject = null;
2155
+ }
1816
2156
  }
1817
2157
  if (!textObject) {
1818
2158
  let labelText = mask.maskName;
@@ -1880,9 +2220,10 @@ var ImageEditor = class {
1880
2220
  if (!mask) return;
1881
2221
  if (!this.options.maskLabelOnSelect) return;
1882
2222
  if (!mask.__label) return;
1883
- const coords = mask.getCoords ? mask.getCoords() : null;
1884
- if (!coords || coords.length < 4) return;
1885
- const tl = coords[0];
2223
+ if (typeof mask.setCoords === "function") mask.setCoords();
2224
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2225
+ if (!bounds) return;
2226
+ const tl = { x: bounds.left, y: bounds.top };
1886
2227
  const center = mask.getCenterPoint();
1887
2228
  const vx = center.x - tl.x;
1888
2229
  const vy = center.y - tl.y;
@@ -1960,7 +2301,7 @@ var ImageEditor = class {
1960
2301
  * @private
1961
2302
  */
1962
2303
  _updateMaskList() {
1963
- const maskListElement = document.getElementById(this.elements.maskList);
2304
+ const maskListElement = this._getElement("maskList");
1964
2305
  if (!maskListElement) return;
1965
2306
  maskListElement.innerHTML = "";
1966
2307
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -1968,13 +2309,20 @@ var ImageEditor = class {
1968
2309
  const listItemElement = document.createElement("li");
1969
2310
  listItemElement.className = "list-group-item mask-item";
1970
2311
  listItemElement.textContent = mask.maskName;
1971
- listItemElement.onclick = () => {
1972
- this.canvas.setActiveObject(mask);
1973
- this._handleSelectionChanged([mask]);
1974
- };
2312
+ listItemElement.dataset.maskId = String(mask.maskId);
1975
2313
  maskListElement.appendChild(listItemElement);
1976
2314
  });
1977
2315
  }
2316
+ _handleMaskListClick(event) {
2317
+ if (!this.canvas) return;
2318
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2319
+ if (!itemElement || !itemElement.dataset) return;
2320
+ const maskId = Number(itemElement.dataset.maskId);
2321
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2322
+ if (!mask) return;
2323
+ this.canvas.setActiveObject(mask);
2324
+ this._handleSelectionChanged([mask]);
2325
+ }
1978
2326
  /**
1979
2327
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
1980
2328
  *
@@ -1982,12 +2330,13 @@ var ImageEditor = class {
1982
2330
  * @private
1983
2331
  */
1984
2332
  _updateMaskListSelection(selectedMask) {
1985
- const maskListElement = document.getElementById(this.elements.maskList);
2333
+ const maskListElement = this._getElement("maskList");
1986
2334
  if (!maskListElement) return;
1987
2335
  const maskItems = maskListElement.querySelectorAll(".mask-item");
1988
2336
  maskItems.forEach((item) => {
1989
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2337
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
1990
2338
  item.classList.toggle("active", isSelected);
2339
+ item.classList.toggle("selected", isSelected);
1991
2340
  });
1992
2341
  }
1993
2342
  /**
@@ -2002,6 +2351,7 @@ var ImageEditor = class {
2002
2351
  */
2003
2352
  async mergeMasks() {
2004
2353
  if (!this.originalImage) return;
2354
+ this._assertIdleForOperation("mergeMasks");
2005
2355
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2006
2356
  if (!masks.length) return;
2007
2357
  this.canvas.discardActiveObject();
@@ -2010,11 +2360,12 @@ var ImageEditor = class {
2010
2360
  const beforeJson = this._serializeCanvasState();
2011
2361
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2012
2362
  this.removeAllMasks({ saveHistory: false });
2013
- await this.loadImage(merged, { preserveScroll: true });
2363
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2014
2364
  const afterJson = this._serializeCanvasState();
2015
2365
  this._pushStateTransition(beforeJson, afterJson);
2016
2366
  } catch (error) {
2017
2367
  this._reportError("merge error", error);
2368
+ throw error;
2018
2369
  }
2019
2370
  }
2020
2371
  /**
@@ -2036,6 +2387,7 @@ var ImageEditor = class {
2036
2387
  */
2037
2388
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2038
2389
  if (!this.originalImage) return;
2390
+ if (!this._canMutateNow("downloadImage")) return;
2039
2391
  const exportImageArea = this.options.exportImageAreaByDefault;
2040
2392
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2041
2393
  const link = document.createElement("a");
@@ -2064,6 +2416,7 @@ var ImageEditor = class {
2064
2416
  */
2065
2417
  async exportImageBase64(options = {}) {
2066
2418
  if (!this.originalImage) throw new Error("No image loaded");
2419
+ this._assertIdleForOperation("exportImageBase64");
2067
2420
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2068
2421
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2069
2422
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2080,7 +2433,7 @@ var ImageEditor = class {
2080
2433
  this.originalImage.setCoords();
2081
2434
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2082
2435
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2083
- return await this._exportCanvasRegionToDataURL({
2436
+ return this._exportCanvasRegionToDataURL({
2084
2437
  ...exportRegion,
2085
2438
  multiplier,
2086
2439
  quality,
@@ -2120,7 +2473,7 @@ var ImageEditor = class {
2120
2473
  this.originalImage.setCoords();
2121
2474
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2122
2475
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2123
- finalBase64 = await this._exportCanvasRegionToDataURL({
2476
+ finalBase64 = this._exportCanvasRegionToDataURL({
2124
2477
  ...exportRegion,
2125
2478
  multiplier,
2126
2479
  quality,
@@ -2176,6 +2529,7 @@ var ImageEditor = class {
2176
2529
  */
2177
2530
  async exportImageFile(options = {}) {
2178
2531
  if (!this.originalImage) throw new Error("No image loaded");
2532
+ this._assertIdleForOperation("exportImageFile");
2179
2533
  const {
2180
2534
  mergeMask = true,
2181
2535
  fileType = "jpeg",
@@ -2211,6 +2565,7 @@ var ImageEditor = class {
2211
2565
  offscreenCanvas.width = imageElement.width;
2212
2566
  offscreenCanvas.height = imageElement.height;
2213
2567
  const context = offscreenCanvas.getContext("2d");
2568
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2214
2569
  context.drawImage(imageElement, 0, 0);
2215
2570
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2216
2571
  resolve(convertedDataUrl);
@@ -2278,7 +2633,9 @@ var ImageEditor = class {
2278
2633
  if (this._cropHandlers && this._cropHandlers.length) {
2279
2634
  this._cropHandlers.forEach((targetHandlers) => {
2280
2635
  targetHandlers.handlers.forEach((handlerRecord) => {
2281
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2636
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2637
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2638
+ }
2282
2639
  });
2283
2640
  });
2284
2641
  }
@@ -2286,7 +2643,7 @@ var ImageEditor = class {
2286
2643
  void error;
2287
2644
  }
2288
2645
  try {
2289
- this.canvas.remove(this._cropRect);
2646
+ if (this.canvas) this.canvas.remove(this._cropRect);
2290
2647
  } catch (error) {
2291
2648
  void error;
2292
2649
  }
@@ -2304,7 +2661,9 @@ var ImageEditor = class {
2304
2661
  */
2305
2662
  enterCropMode() {
2306
2663
  if (!this.canvas || !this.originalImage || this._cropMode) return;
2664
+ if (!this._canMutateNow("enterCropMode")) return;
2307
2665
  if (!this.isImageLoaded()) return;
2666
+ this._removeCropRect();
2308
2667
  this._cropMode = true;
2309
2668
  this._prevSelectionSetting = this.canvas.selection;
2310
2669
  this.canvas.selection = false;
@@ -2420,6 +2779,7 @@ var ImageEditor = class {
2420
2779
  */
2421
2780
  async applyCrop() {
2422
2781
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2782
+ this._assertIdleForOperation("applyCrop");
2423
2783
  this._cropRect.setCoords();
2424
2784
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2425
2785
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
@@ -2444,12 +2804,8 @@ var ImageEditor = class {
2444
2804
  this._removeLabelForMask(mask);
2445
2805
  this.canvas.remove(mask);
2446
2806
  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();
2807
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2808
+ mask.set({ visible: true });
2453
2809
  preservedMasks.push(mask);
2454
2810
  }
2455
2811
  } catch (error) {
@@ -2480,7 +2836,7 @@ var ImageEditor = class {
2480
2836
  return;
2481
2837
  }
2482
2838
  try {
2483
- await this.loadImage(croppedBase64);
2839
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2484
2840
  if (preservedMasks.length) {
2485
2841
  preservedMasks.forEach((mask) => {
2486
2842
  this._rebindMaskEvents(mask);
@@ -2498,7 +2854,7 @@ var ImageEditor = class {
2498
2854
  }
2499
2855
  let afterJson;
2500
2856
  try {
2501
- afterJson = this._serializeCanvasState();
2857
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2502
2858
  } catch (error) {
2503
2859
  this._reportWarning("applyCrop: failed to serialize after state", error);
2504
2860
  afterJson = null;
@@ -2518,7 +2874,7 @@ var ImageEditor = class {
2518
2874
  * @private
2519
2875
  */
2520
2876
  _updateInputs() {
2521
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
2877
+ const scaleInputElement = this._getElement("scaleRate");
2522
2878
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2523
2879
  }
2524
2880
  /**
@@ -2527,6 +2883,7 @@ var ImageEditor = class {
2527
2883
  * @private
2528
2884
  */
2529
2885
  _updateUI() {
2886
+ if (!this.canvas) return;
2530
2887
  const hasImage = !!this.originalImage;
2531
2888
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2532
2889
  const hasMasks = masks.length > 0;
@@ -2538,7 +2895,7 @@ var ImageEditor = class {
2538
2895
  const isInCropMode = !!this._cropMode;
2539
2896
  if (isInCropMode) {
2540
2897
  for (const key of Object.keys(this.elements || {})) {
2541
- const element = document.getElementById(this.elements[key]);
2898
+ const element = this._getElement(key);
2542
2899
  if (!element) continue;
2543
2900
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2544
2901
  this._setDisabled(key, false);
@@ -2574,7 +2931,7 @@ var ImageEditor = class {
2574
2931
  * @private
2575
2932
  */
2576
2933
  _setDisabled(key, disabled) {
2577
- const element = document.getElementById(this.elements[key]);
2934
+ const element = this._getElement(key);
2578
2935
  if (!element) return;
2579
2936
  if ("disabled" in element) {
2580
2937
  element.disabled = !!disabled;
@@ -2608,9 +2965,18 @@ var ImageEditor = class {
2608
2965
  * @private
2609
2966
  */
2610
2967
  _setPlaceholderVisible(show) {
2611
- if (!this.placeholderElement || !this.containerElement) return;
2612
- this._setElementVisible(this.placeholderElement, show);
2613
- this._setElementVisible(this.containerElement, !show);
2968
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
2969
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
2970
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
2971
+ this._setElementVisible(canvasVisibilityElement, !show);
2972
+ }
2973
+ }
2974
+ _getCanvasVisibilityElement() {
2975
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
2976
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
2977
+ return wrapperElement || this.canvasElement;
2978
+ }
2979
+ return this.containerElement || wrapperElement || this.canvasElement;
2614
2980
  }
2615
2981
  /**
2616
2982
  * Updates element visibility.
@@ -2622,9 +2988,34 @@ var ImageEditor = class {
2622
2988
  */
2623
2989
  _setElementVisible(element, isVisible) {
2624
2990
  if (!element) return;
2991
+ this._rememberElementVisibility(element);
2625
2992
  element.hidden = !isVisible;
2626
2993
  element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2627
- if (isVisible && element.classList) element.classList.remove("d-none");
2994
+ if (element.classList) {
2995
+ element.classList.toggle("d-none", !isVisible);
2996
+ }
2997
+ }
2998
+ _rememberElementVisibility(element) {
2999
+ if (!element || this._visibilityStateByElement.has(element)) return;
3000
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3001
+ }
3002
+ _captureElementVisibility(element) {
3003
+ if (!element) return null;
3004
+ return {
3005
+ hidden: element.hidden,
3006
+ ariaHidden: element.getAttribute("aria-hidden"),
3007
+ className: element.className
3008
+ };
3009
+ }
3010
+ _restoreElementVisibility(element, state) {
3011
+ if (!element || !state) return;
3012
+ element.hidden = !!state.hidden;
3013
+ if (state.ariaHidden === null) {
3014
+ element.removeAttribute("aria-hidden");
3015
+ } else {
3016
+ element.setAttribute("aria-hidden", state.ariaHidden);
3017
+ }
3018
+ element.className = state.className || "";
2628
3019
  }
2629
3020
  /**
2630
3021
  * Cleans up and disposes of the canvas and related references.
@@ -2632,10 +3023,14 @@ var ImageEditor = class {
2632
3023
  * @public
2633
3024
  */
2634
3025
  dispose() {
3026
+ this._disposed = true;
3027
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3028
+ if (this.animationQueue) {
3029
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3030
+ }
2635
3031
  try {
2636
- for (const key in this._handlersByElementKey || {}) {
2637
- const handlers = this._handlersByElementKey[key] || [];
2638
- const element = document.getElementById(this.elements[key]);
3032
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3033
+ const element = this._getElement(key);
2639
3034
  if (!element) continue;
2640
3035
  handlers.forEach((handlerRecord) => {
2641
3036
  try {
@@ -2656,9 +3051,28 @@ var ImageEditor = class {
2656
3051
  }
2657
3052
  this._cropRect = null;
2658
3053
  }
2659
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3054
+ if (this.containerElement && this._containerOriginalOverflow) {
3055
+ try {
3056
+ this._restoreContainerOverflowState();
3057
+ } catch (error) {
3058
+ void error;
3059
+ }
3060
+ }
3061
+ if (this._visibilityStateByElement) {
3062
+ try {
3063
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3064
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3065
+ if (state) this._restoreElementVisibility(element, state);
3066
+ });
3067
+ } catch (error) {
3068
+ void error;
3069
+ }
3070
+ }
3071
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2660
3072
  try {
2661
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3073
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3074
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3075
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2662
3076
  } catch (error) {
2663
3077
  void error;
2664
3078
  }
@@ -2674,6 +3088,19 @@ var ImageEditor = class {
2674
3088
  this.isImageLoadedToCanvas = false;
2675
3089
  }
2676
3090
  this._handlersByElementKey = {};
3091
+ this._elementCache = {};
3092
+ this._clearMaskPlacementMemory();
3093
+ this.originalImage = null;
3094
+ this.baseImageScale = 1;
3095
+ this.currentScale = 1;
3096
+ this.currentRotation = 0;
3097
+ this.isAnimating = false;
3098
+ this._cropMode = false;
3099
+ this._cropRect = null;
3100
+ this._cropHandlers = [];
3101
+ this._cropPrevEvented = null;
3102
+ this._prevSelectionSetting = void 0;
3103
+ this._initialized = false;
2677
3104
  }
2678
3105
  };
2679
3106
  var AnimationQueue = class {
@@ -2683,6 +3110,7 @@ var AnimationQueue = class {
2683
3110
  constructor() {
2684
3111
  this.animationTasks = [];
2685
3112
  this.isRunning = false;
3113
+ this.currentTask = null;
2686
3114
  }
2687
3115
  /**
2688
3116
  * Adds an animation function to the queue.
@@ -2692,12 +3120,29 @@ var AnimationQueue = class {
2692
3120
  */
2693
3121
  async add(animationFn) {
2694
3122
  return new Promise((resolve, reject) => {
2695
- this.animationTasks.push({ animationFn, resolve, reject });
3123
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2696
3124
  if (!this.isRunning) {
2697
3125
  this._drainQueue();
2698
3126
  }
2699
3127
  });
2700
3128
  }
3129
+ isBusy() {
3130
+ return this.isRunning || this.animationTasks.length > 0;
3131
+ }
3132
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3133
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3134
+ const tasks = [
3135
+ ...this.currentTask ? [this.currentTask] : [],
3136
+ ...this.animationTasks.splice(0)
3137
+ ];
3138
+ tasks.forEach((task) => {
3139
+ if (!task || task.isSettled) return;
3140
+ task.isSettled = true;
3141
+ task.reject(cancellationError);
3142
+ });
3143
+ this.isRunning = false;
3144
+ this.currentTask = null;
3145
+ }
2701
3146
  /**
2702
3147
  * Runs queued animation tasks sequentially until the queue is empty.
2703
3148
  *
@@ -2705,19 +3150,27 @@ var AnimationQueue = class {
2705
3150
  * @returns {Promise<void>}
2706
3151
  */
2707
3152
  async _drainQueue() {
2708
- if (this.animationTasks.length === 0) {
2709
- this.isRunning = false;
2710
- return;
2711
- }
3153
+ if (this.isRunning) return;
2712
3154
  this.isRunning = true;
2713
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2714
- try {
2715
- const result = await animationFn();
2716
- resolve(result);
2717
- } catch (error) {
2718
- reject(error);
3155
+ while (this.animationTasks.length > 0) {
3156
+ const task = this.animationTasks.shift();
3157
+ this.currentTask = task;
3158
+ try {
3159
+ const result = await task.animationFn();
3160
+ if (!task.isSettled) {
3161
+ task.isSettled = true;
3162
+ task.resolve(result);
3163
+ }
3164
+ } catch (error) {
3165
+ if (!task.isSettled) {
3166
+ task.isSettled = true;
3167
+ task.reject(error);
3168
+ }
3169
+ } finally {
3170
+ if (this.currentTask === task) this.currentTask = null;
3171
+ }
2719
3172
  }
2720
- await this._drainQueue();
3173
+ this.isRunning = false;
2721
3174
  }
2722
3175
  };
2723
3176
  var Command = class {
@@ -2748,15 +3201,8 @@ var HistoryManager = class {
2748
3201
  * @private
2749
3202
  */
2750
3203
  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;
3204
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3205
+ this.pending = nextTask.catch(() => void 0);
2760
3206
  return nextTask;
2761
3207
  }
2762
3208
  /**
@@ -2767,8 +3213,14 @@ var HistoryManager = class {
2767
3213
  * @returns {void}
2768
3214
  */
2769
3215
  execute(command) {
2770
- command.execute();
3216
+ const result = command.execute();
3217
+ if (result && typeof result.then === "function") {
3218
+ return Promise.resolve(result).then(() => {
3219
+ this.push(command);
3220
+ });
3221
+ }
2771
3222
  this.push(command);
3223
+ return result;
2772
3224
  }
2773
3225
  /**
2774
3226
  * Pushes an already-applied command onto the history stack.
@@ -2784,9 +3236,8 @@ var HistoryManager = class {
2784
3236
  this.history.push(command);
2785
3237
  if (this.history.length > this.maxSize) {
2786
3238
  this.history.shift();
2787
- } else {
2788
- this.currentIndex++;
2789
3239
  }
3240
+ this.currentIndex = this.history.length - 1;
2790
3241
  }
2791
3242
  /**
2792
3243
  * Checks whether an undo operation is possible.