@bensitu/image-editor 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.3.0
4
+ * @version 1.4.0
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -196,6 +196,8 @@ function ensureFabric() {
196
196
  downsampleMaxWidth: 4000,
197
197
  downsampleMaxHeight: 3000,
198
198
  downsampleQuality: 0.92,
199
+ preserveSourceFormat: true,
200
+ downsampleMimeType: null,
199
201
  imageLoadTimeoutMs: 30000,
200
202
 
201
203
  exportMultiplier: 1,
@@ -255,6 +257,7 @@ function ensureFabric() {
255
257
  this.maxHistorySize = 50;
256
258
 
257
259
  this._handlersByElementKey = {};
260
+ this._elementCache = {};
258
261
 
259
262
  this._lastMask = null;
260
263
  this._lastMaskInitialLeft = null;
@@ -267,8 +270,14 @@ function ensureFabric() {
267
270
  this._cropHandlers = [];
268
271
  this._cropPrevEvented = null;
269
272
  this._prevSelectionSetting = undefined;
270
- this._containerOriginalOverflow = undefined;
273
+ this._containerOriginalOverflow = null;
274
+ this._lastContainerViewportSize = null;
275
+ this._canvasElementOriginalStyle = null;
276
+ this._visibilityStateByElement = new WeakMap();
271
277
  this._scrollbarSizeCache = null;
278
+ this._activeAnimationRejectors = new Set();
279
+ this._disposed = false;
280
+ this._initialized = false;
272
281
 
273
282
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
274
283
 
@@ -341,6 +350,16 @@ function ensureFabric() {
341
350
  */
342
351
  init(idMap = {}) {
343
352
  if (!this._fabricLoaded) return;
353
+ if (this._initialized || this.canvas) this.dispose();
354
+ this._disposed = false;
355
+ this._initialized = true;
356
+ this.animationQueue = new AnimationQueue();
357
+ this.historyManager = new HistoryManager(this.maxHistorySize);
358
+ this._visibilityStateByElement = new WeakMap();
359
+ this._activeAnimationRejectors = new Set();
360
+ this._containerOriginalOverflow = null;
361
+ this._lastContainerViewportSize = null;
362
+ this._canvasElementOriginalStyle = null;
344
363
 
345
364
  const defaults = {
346
365
  canvas: 'fabricCanvas',
@@ -369,6 +388,7 @@ function ensureFabric() {
369
388
  };
370
389
 
371
390
  this.elements = { ...defaults, ...idMap };
391
+ this._elementCache = {};
372
392
 
373
393
  this._initCanvas();
374
394
  this._bindEvents();
@@ -413,19 +433,25 @@ function ensureFabric() {
413
433
  * @private
414
434
  */
415
435
  _initCanvas() {
416
- const canvasElement = document.getElementById(this.elements.canvas);
436
+ const canvasElement = this._getElement('canvas');
417
437
  if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
418
438
  this.canvasElement = canvasElement;
439
+ this._canvasElementOriginalStyle = {
440
+ display: canvasElement.style.display || '',
441
+ width: canvasElement.style.width || '',
442
+ height: canvasElement.style.height || '',
443
+ maxWidth: canvasElement.style.maxWidth || ''
444
+ };
419
445
 
420
446
  // Decide which element acts as the viewport for size fallback and scrolling.
421
447
  if (this.elements.canvasContainer) {
422
- const containerElement = document.getElementById(this.elements.canvasContainer);
448
+ const containerElement = this._getElement('canvasContainer');
423
449
  this.containerElement = containerElement || canvasElement.parentElement;
424
450
  } else {
425
451
  this.containerElement = canvasElement.parentElement;
426
452
  }
427
453
 
428
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
454
+ this.placeholderElement = this._getElement('imgPlaceholder') || null;
429
455
 
430
456
  // Prefer a measured container size when it is available.
431
457
  let initialWidth = this.options.canvasWidth;
@@ -436,6 +462,11 @@ function ensureFabric() {
436
462
  if (containerWidth > 0 && containerHeight > 0) {
437
463
  initialWidth = containerWidth;
438
464
  initialHeight = containerHeight;
465
+
466
+ this._lastContainerViewportSize = {
467
+ width: containerWidth,
468
+ height: containerHeight
469
+ };
439
470
  }
440
471
  }
441
472
 
@@ -460,6 +491,24 @@ function ensureFabric() {
460
491
  this.canvasElement.style.display = 'block';
461
492
  }
462
493
 
494
+ /**
495
+ * Returns a configured DOM element and caches lookups for hot UI paths.
496
+ *
497
+ * @param {string} key - Key in the configured element map.
498
+ * @returns {HTMLElement|null} The configured element, or null when missing.
499
+ * @private
500
+ */
501
+ _getElement(key) {
502
+ const id = this.elements && this.elements[key];
503
+ if (!id) return null;
504
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
505
+ return this._elementCache[key];
506
+ }
507
+ const element = document.getElementById(id);
508
+ if (this._elementCache) this._elementCache[key] = element || null;
509
+ return element || null;
510
+ }
511
+
463
512
  /**
464
513
  * Records a history entry after Fabric finishes modifying one or more masks.
465
514
  *
@@ -504,9 +553,7 @@ function ensureFabric() {
504
553
  */
505
554
  _syncContainerOverflow(options = {}) {
506
555
  if (!this.containerElement || !this.containerElement.style) return;
507
- if (this._containerOriginalOverflow === undefined) {
508
- this._containerOriginalOverflow = this.containerElement.style.overflow || '';
509
- }
556
+ this._captureContainerOverflowState();
510
557
 
511
558
  const shouldPreserveScroll = options.preserveScroll === true;
512
559
  if (this.options.coverImageToCanvas) {
@@ -522,10 +569,26 @@ function ensureFabric() {
522
569
  this.containerElement.scrollTop = 0;
523
570
  }
524
571
  } else {
525
- this.containerElement.style.overflow = this._containerOriginalOverflow;
572
+ this._restoreContainerOverflowState();
526
573
  }
527
574
  }
528
575
 
576
+ _captureContainerOverflowState() {
577
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
578
+ this._containerOriginalOverflow = {
579
+ overflow: this.containerElement.style.overflow || '',
580
+ overflowX: this.containerElement.style.overflowX || '',
581
+ overflowY: this.containerElement.style.overflowY || ''
582
+ };
583
+ }
584
+
585
+ _restoreContainerOverflowState() {
586
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
587
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
588
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
589
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
590
+ }
591
+
529
592
  /**
530
593
  * DOM / UI bindings
531
594
  * @private
@@ -533,54 +596,61 @@ function ensureFabric() {
533
596
  _bindEvents() {
534
597
  // Click anywhere on the upload area opens the native file dialog
535
598
  this._bindIfExists('uploadArea', 'click', () => {
536
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
599
+ const uploadAreaElement = this._getElement('uploadArea');
537
600
  if (this._isElementDisabled(uploadAreaElement)) return;
538
- document.getElementById(this.elements.imageInput)?.click();
601
+ this._getElement('imageInput')?.click();
539
602
  });
540
603
  // File-input change
541
604
  this._bindIfExists('imageInput', 'change', (event) => {
542
605
  const file = event.target.files && event.target.files[0];
543
- if (file) this._loadImageFile(file);
606
+ if (file) {
607
+ this._loadImageFile(file)
608
+ .catch(error => this._reportError('Image file could not be loaded', error))
609
+ .finally(() => {
610
+ event.target.value = '';
611
+ });
612
+ }
544
613
  });
545
614
  // Zoom & reset
546
- this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
547
- this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
548
- this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
615
+ this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
616
+ this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
617
+ this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
549
618
  // Mask management
550
619
  this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
551
620
  this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
552
621
  this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
553
622
  // Merge + download
554
- this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
623
+ this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
555
624
  this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
556
625
  // Undo + Redo
557
- this._bindIfExists('undoBtn', 'click', () => this.undo());
558
- this._bindIfExists('redoBtn', 'click', () => this.redo());
626
+ this._bindIfExists('undoBtn', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
627
+ this._bindIfExists('redoBtn', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
559
628
 
560
629
  // Rotation buttons (step can be overridden by two input fields)
561
630
  this._bindIfExists('rotateLeftBtn', 'click', () => {
562
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
631
+ const rotationInputElement = this._getElement('rotationLeftInput');
563
632
  let step = this.options.rotationStep;
564
633
  if (rotationInputElement) {
565
634
  const parsedStep = parseFloat(rotationInputElement.value);
566
635
  if (!isNaN(parsedStep)) step = parsedStep;
567
636
  }
568
- this.rotateImage(this.currentRotation - step);
637
+ this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
569
638
  });
570
639
  this._bindIfExists('rotateRightBtn', 'click', () => {
571
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
640
+ const rotationInputElement = this._getElement('rotationRightInput');
572
641
  let step = this.options.rotationStep;
573
642
  if (rotationInputElement) {
574
643
  const parsedStep = parseFloat(rotationInputElement.value);
575
644
  if (!isNaN(parsedStep)) step = parsedStep;
576
645
  }
577
- this.rotateImage(this.currentRotation + step);
646
+ this.rotateImage(this.currentRotation + step).catch(error => this._reportError('rotateImage failed', error));
578
647
  });
579
648
 
580
649
  // Crop bindings (optional: bound only if element IDs exist in elements)
581
650
  this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
582
651
  this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
583
652
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
653
+ this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
584
654
  }
585
655
 
586
656
  /**
@@ -592,7 +662,7 @@ function ensureFabric() {
592
662
  * @private
593
663
  */
594
664
  _bindIfExists(key, eventName, handler) {
595
- const element = document.getElementById(this.elements[key]);
665
+ const element = this._getElement(key);
596
666
  if (element) {
597
667
  element.addEventListener(eventName, handler);
598
668
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -605,14 +675,37 @@ function ensureFabric() {
605
675
  * Reads an image File as a data URL and loads it into the Fabric canvas.
606
676
  *
607
677
  * @param {File} file - Image file selected by the user.
678
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
608
679
  * @private
609
680
  */
610
681
  _loadImageFile(file) {
611
- if (!file || !file.type.startsWith('image/')) return;
612
- const reader = new FileReader();
613
- reader.onload = (event) => this.loadImage(event.target.result);
614
- reader.onerror = (event) => { this._reportError('Image file could not be read', event); };
615
- reader.readAsDataURL(file);
682
+ if (!this._isSupportedImageFile(file)) {
683
+ const error = new Error('Selected file is not a supported image');
684
+ this._reportError('Selected file is not a supported image', error);
685
+ return Promise.reject(error);
686
+ }
687
+
688
+ return new Promise((resolve, reject) => {
689
+ const reader = new FileReader();
690
+ reader.onload = (event) => {
691
+ this.loadImage(event.target.result)
692
+ .then(resolve)
693
+ .catch(reject);
694
+ };
695
+ reader.onerror = (event) => {
696
+ const error = new Error('Image file could not be read');
697
+ this._reportError('Image file could not be read', event);
698
+ reject(error);
699
+ };
700
+ reader.readAsDataURL(file);
701
+ });
702
+ }
703
+
704
+ _isSupportedImageFile(file) {
705
+ if (!file) return false;
706
+ if (typeof file.type === 'string' && file.type.startsWith('image/')) return true;
707
+ const fileName = String(file.name || '');
708
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
616
709
  }
617
710
 
618
711
  /**
@@ -645,120 +738,113 @@ function ensureFabric() {
645
738
  */
646
739
  async loadImage(imageBase64, options = {}) {
647
740
  if (!this._fabricLoaded) return;
648
- if (!this.canvas) return;
741
+ if (!this.canvas || this._disposed) return;
649
742
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
743
+ this._assertIdleForOperation('loadImage');
650
744
 
651
745
  this._warnOnImageLayoutOptionConflict();
652
- this._setPlaceholderVisible(false);
653
- this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
654
-
655
- const imageElement = await this._createImageElement(imageBase64);
656
-
657
- let loadSource = imageBase64;
658
- if (this.options.downsampleOnLoad) {
659
- const shouldResize =
660
- imageElement.naturalWidth > this.options.downsampleMaxWidth ||
661
- imageElement.naturalHeight > this.options.downsampleMaxHeight;
662
- if (shouldResize) {
663
- const ratio = Math.min(
664
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
665
- this.options.downsampleMaxHeight / imageElement.naturalHeight
666
- );
667
- const targetWidth = Math.round(imageElement.naturalWidth * ratio);
668
- const targetHeight = Math.round(imageElement.naturalHeight * ratio);
669
- loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
746
+ const transaction = this._captureLoadImageTransaction();
747
+
748
+ try {
749
+ const imageElement = await this._createImageElement(imageBase64);
750
+ if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
751
+
752
+ let loadSource = imageBase64;
753
+ if (this.options.downsampleOnLoad) {
754
+ const shouldResize =
755
+ imageElement.naturalWidth > this.options.downsampleMaxWidth ||
756
+ imageElement.naturalHeight > this.options.downsampleMaxHeight;
757
+ if (shouldResize) {
758
+ const ratio = Math.min(
759
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
760
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
761
+ );
762
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
763
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
764
+ loadSource = this._resampleImageToDataURL(
765
+ imageElement,
766
+ targetWidth,
767
+ targetHeight,
768
+ this.options.downsampleQuality,
769
+ imageBase64
770
+ );
771
+ }
670
772
  }
671
- }
672
773
 
673
- // Create fabric.Image from URL
674
- return new Promise((resolve, reject) => {
675
- fabric.Image.fromURL(loadSource, (fabricImage) => {
676
- try {
677
- if (!fabricImage) throw new Error('Image could not be loaded');
678
-
679
- this.canvas.discardActiveObject();
680
- this._hideAllMaskLabels();
681
- this.canvas.clear();
682
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
683
-
684
- fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
685
-
686
- const imageWidth = fabricImage.width;
687
- const imageHeight = fabricImage.height;
688
-
689
- const viewport = this._getContainerViewportSize();
690
- const minWidth = viewport.width;
691
- const minHeight = viewport.height;
692
-
693
- if (this.options.fitImageToCanvas) {
694
- // Fit into the visible viewport, shrinking only when the image is larger.
695
- const canvasWidth = Math.max(1, minWidth - 1);
696
- const canvasHeight = Math.max(1, minHeight - 1);
697
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
698
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
699
- fabricImage.set({ left: 0, top: 0 });
700
- fabricImage.scale(fitScale);
701
- this.baseImageScale = fabricImage.scaleX || 1;
702
- } else if (this.options.coverImageToCanvas) {
703
- const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
704
- this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
705
- fabricImage.set({ left: 0, top: 0 });
706
- fabricImage.scale(layout.scale);
707
- this.baseImageScale = fabricImage.scaleX || 1;
708
- } else if (this.options.expandCanvasToImage) {
709
- // Expand canvas so that it fully contains the image
710
- const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
711
- const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
712
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
713
- fabricImage.set({ left: 0, top: 0 });
714
- fabricImage.scale(1);
715
- this.baseImageScale = 1;
716
- } else {
717
- // Keep existing canvas size and center the image
718
- const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
719
- const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
720
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
721
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
722
- fabricImage.set({ left: 0, top: 0 });
723
- fabricImage.scale(fitScale);
724
- this.baseImageScale = fabricImage.scaleX || 1;
725
- }
726
- // Put the image onto the canvas
727
- this.originalImage = fabricImage;
728
- this.canvas.add(fabricImage);
729
- this.canvas.sendToBack(fabricImage);
730
-
731
- // Reset mask placement memory
732
- this._lastMask = null;
733
- this._lastMaskInitialLeft = null;
734
- this._lastMaskInitialTop = null;
735
- this._lastMaskInitialWidth = null;
736
-
737
- this.maskCounter = 0;
738
- this.currentScale = 1;
739
- this.currentRotation = 0;
740
-
741
- this._updateInputs();
742
- this._updateMaskList();
743
- this.isImageLoadedToCanvas = true;
744
- this._updateUI();
745
- this.canvas.renderAll();
746
- try {
747
- this._lastSnapshot = this._serializeCanvasState();
748
- } catch (error) {
749
- this._reportWarning('loadImage: failed to capture initial canvas snapshot', error);
750
- }
774
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
775
+ if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
776
+
777
+ this.canvas.discardActiveObject();
778
+ this._hideAllMaskLabels();
779
+ this.canvas.clear();
780
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
781
+
782
+ fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
783
+ this._setPlaceholderVisible(false);
784
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
785
+
786
+ const imageWidth = fabricImage.width;
787
+ const imageHeight = fabricImage.height;
788
+
789
+ const viewport = this._getContainerViewportSize();
790
+ const minWidth = viewport.width;
791
+ const minHeight = viewport.height;
792
+
793
+ if (this.options.fitImageToCanvas) {
794
+ const canvasWidth = Math.max(1, minWidth - 1);
795
+ const canvasHeight = Math.max(1, minHeight - 1);
796
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
797
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
798
+ fabricImage.set({ left: 0, top: 0 });
799
+ fabricImage.scale(fitScale);
800
+ this.baseImageScale = fabricImage.scaleX || 1;
801
+ } else if (this.options.coverImageToCanvas) {
802
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
803
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
804
+ fabricImage.set({ left: 0, top: 0 });
805
+ fabricImage.scale(layout.scale);
806
+ this.baseImageScale = fabricImage.scaleX || 1;
807
+ } else if (this.options.expandCanvasToImage) {
808
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
809
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
810
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
811
+ fabricImage.set({ left: 0, top: 0 });
812
+ fabricImage.scale(1);
813
+ this.baseImageScale = 1;
814
+ } else {
815
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
816
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
817
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
818
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
819
+ fabricImage.set({ left: 0, top: 0 });
820
+ fabricImage.scale(fitScale);
821
+ this.baseImageScale = fabricImage.scaleX || 1;
822
+ }
751
823
 
752
- if (typeof this.onImageLoaded === 'function') {
753
- this.onImageLoaded();
754
- }
824
+ this.originalImage = fabricImage;
825
+ this.canvas.add(fabricImage);
826
+ this.canvas.sendToBack(fabricImage);
755
827
 
756
- resolve();
757
- } catch (error) {
758
- reject(error);
759
- }
760
- }, { crossOrigin: 'anonymous' });
761
- });
828
+ this._clearMaskPlacementMemory();
829
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
830
+ this.currentScale = 1;
831
+ this.currentRotation = 0;
832
+
833
+ // this._setPlaceholderVisible(false);
834
+ this._updateInputs();
835
+ this._updateMaskList();
836
+ this.isImageLoadedToCanvas = true;
837
+ this._updateUI();
838
+ this.canvas.renderAll();
839
+ this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
840
+
841
+ if (typeof this.onImageLoaded === 'function') {
842
+ this.onImageLoaded();
843
+ }
844
+ } catch (error) {
845
+ await this._rollbackLoadImageTransaction(transaction);
846
+ throw error;
847
+ }
762
848
  }
763
849
 
764
850
  /**
@@ -810,24 +896,142 @@ function ensureFabric() {
810
896
  });
811
897
  }
812
898
 
899
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
900
+ return new Promise((resolve, reject) => {
901
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
902
+ let isSettled = false;
903
+ let timerId;
904
+ const settle = (callback) => {
905
+ if (isSettled) return;
906
+ isSettled = true;
907
+ clearTimeout(timerId);
908
+ callback();
909
+ };
910
+
911
+ timerId = setTimeout(() => {
912
+ settle(() => reject(new Error('Fabric image load timed out')));
913
+ }, safeTimeoutMs);
914
+
915
+ try {
916
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
917
+ settle(() => {
918
+ if (!fabricImage) {
919
+ reject(new Error('Image could not be loaded'));
920
+ return;
921
+ }
922
+ resolve(fabricImage);
923
+ });
924
+ }, { crossOrigin: 'anonymous' });
925
+ } catch (error) {
926
+ settle(() => reject(error));
927
+ }
928
+ });
929
+ }
930
+
931
+ _getSafeTimeoutMs(timeoutMs) {
932
+ const safeTimeoutMs = Number(timeoutMs);
933
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 30000;
934
+ }
935
+
936
+ _captureLoadImageTransaction() {
937
+ return {
938
+ canvasState: this._serializeCanvasState(),
939
+ originalImage: this.originalImage,
940
+ baseImageScale: this.baseImageScale,
941
+ currentScale: this.currentScale,
942
+ currentRotation: this.currentRotation,
943
+ maskCounter: this.maskCounter,
944
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
945
+ lastSnapshot: this._lastSnapshot,
946
+ lastMask: this._lastMask,
947
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
948
+ lastMaskInitialTop: this._lastMaskInitialTop,
949
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
950
+ containerOverflow: this.containerElement && this.containerElement.style ? {
951
+ overflow: this.containerElement.style.overflow || '',
952
+ overflowX: this.containerElement.style.overflowX || '',
953
+ overflowY: this.containerElement.style.overflowY || ''
954
+ } : null,
955
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
956
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
957
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
958
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
959
+ };
960
+ }
961
+
962
+ async _rollbackLoadImageTransaction(transaction) {
963
+ if (!transaction || !this.canvas || this._disposed) return;
964
+ try {
965
+ if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
966
+ } catch (error) {
967
+ this._reportError('loadImage rollback failed', error);
968
+ }
969
+
970
+ this.baseImageScale = transaction.baseImageScale;
971
+ this.currentScale = transaction.currentScale;
972
+ this.currentRotation = transaction.currentRotation;
973
+ this.maskCounter = transaction.maskCounter;
974
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
975
+ this._lastSnapshot = transaction.lastSnapshot;
976
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
977
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
978
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
979
+ this._containerOriginalOverflow = transaction.containerOverflow;
980
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
981
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
982
+ if (this.containerElement) {
983
+ this.containerElement.scrollLeft = transaction.scrollLeft;
984
+ this.containerElement.scrollTop = transaction.scrollTop;
985
+ this._restoreContainerOverflowState();
986
+ }
987
+ this._updateInputs();
988
+ this._updateMaskList();
989
+ this._updateUI();
990
+ if (this.canvas) this.canvas.renderAll();
991
+ }
992
+
813
993
  /**
814
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
994
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
815
995
  *
816
996
  * @param {HTMLImageElement} imageElement - The image element to resample.
817
997
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
818
998
  * @param {number} targetHeight - Target height (in pixels) for the resampled image.
819
- * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
820
- * @returns {string} A data URL representing the resampled image as JPEG.
999
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
1000
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
1001
+ * @returns {string} A data URL representing the resampled image.
821
1002
  * @private
822
1003
  */
823
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
1004
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
824
1005
  const offscreenCanvas = document.createElement('canvas');
825
1006
  offscreenCanvas.width = targetWidth;
826
1007
  offscreenCanvas.height = targetHeight;
827
1008
  const context = offscreenCanvas.getContext('2d');
828
1009
  if (!context) throw new Error('2D canvas context is unavailable');
829
1010
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
830
- return offscreenCanvas.toDataURL('image/jpeg', quality);
1011
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
1012
+ }
1013
+
1014
+ _getDataUrlMimeType(dataUrl) {
1015
+ const match = String(dataUrl || '').match(/^data:([^;,]+)[;,]/i);
1016
+ return match ? match[1].toLowerCase() : '';
1017
+ }
1018
+
1019
+ _getDownsampleMimeType(sourceDataUrl) {
1020
+ if (this.options.downsampleMimeType) {
1021
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
1022
+ return `image/${requestedFormat}`;
1023
+ }
1024
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
1025
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === 'image/png' || sourceMimeType === 'image/webp')) {
1026
+ return sourceMimeType;
1027
+ }
1028
+ return 'image/jpeg';
1029
+ }
1030
+
1031
+ _captureCanvasStateOrThrow(context) {
1032
+ const snapshot = this._serializeCanvasState();
1033
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
1034
+ return snapshot;
831
1035
  }
832
1036
 
833
1037
  /**
@@ -849,7 +1053,6 @@ function ensureFabric() {
849
1053
  if (this.canvasElement) {
850
1054
  this.canvasElement.style.width = integerWidth + 'px';
851
1055
  this.canvasElement.style.height = integerHeight + 'px';
852
- this.canvasElement.style.maxWidth = 'none';
853
1056
  }
854
1057
  }
855
1058
 
@@ -868,21 +1071,42 @@ function ensureFabric() {
868
1071
  };
869
1072
  }
870
1073
 
1074
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
1075
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
1076
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
1077
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
1078
+
1079
+ if (measuredWidth > 0 && measuredHeight > 0) {
1080
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
1081
+ }
1082
+
871
1083
  if (this._hasFixedContainerScrollbars()) {
872
- return {
873
- width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
874
- height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
875
- };
1084
+ return { width, height };
876
1085
  }
877
1086
 
878
- const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
879
- const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
1087
+ const overflow = this._getContainerOverflowValues();
1088
+ const canScrollX = overflow.x.some(value => value === 'auto' || value === 'scroll');
1089
+ const canScrollY = overflow.y.some(value => value === 'auto' || value === 'scroll');
1090
+ const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
1091
+ const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
1092
+
1093
+ if (hasHorizontalScrollbar || hasVerticalScrollbar) {
1094
+ const scrollbar = this._getScrollbarSize();
1095
+ if (hasVerticalScrollbar) width += scrollbar.width;
1096
+ if (hasHorizontalScrollbar) height += scrollbar.height;
1097
+ }
880
1098
 
881
1099
  return { width, height };
882
1100
  }
883
1101
 
884
- _hasFixedContainerScrollbars() {
885
- if (!this.containerElement) return false;
1102
+ /**
1103
+ * Reads inline and computed overflow values for both scroll axes.
1104
+ *
1105
+ * @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
1106
+ * @private
1107
+ */
1108
+ _getContainerOverflowValues() {
1109
+ if (!this.containerElement) return { x: [], y: [] };
886
1110
  const inlineOverflow = this.containerElement.style.overflow;
887
1111
  const inlineOverflowX = this.containerElement.style.overflowX;
888
1112
  const inlineOverflowY = this.containerElement.style.overflowY;
@@ -897,8 +1121,16 @@ function ensureFabric() {
897
1121
  computedOverflowY = style.overflowY;
898
1122
  }
899
1123
 
900
- return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY]
901
- .some(value => value === 'scroll');
1124
+ return {
1125
+ x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
1126
+ y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
1127
+ };
1128
+ }
1129
+
1130
+ _hasFixedContainerScrollbars() {
1131
+ if (!this.containerElement) return false;
1132
+ const overflow = this._getContainerOverflowValues();
1133
+ return [...overflow.x, ...overflow.y].some(value => value === 'scroll');
902
1134
  }
903
1135
 
904
1136
  _getScrollbarSize() {
@@ -948,8 +1180,8 @@ function ensureFabric() {
948
1180
  const scrollbar = this._getScrollbarSize();
949
1181
  let hasVertical = false;
950
1182
  let hasHorizontal = false;
951
- let effectiveWidth = viewport.width;
952
- let effectiveHeight = viewport.height;
1183
+ let effectiveWidth;
1184
+ let effectiveHeight;
953
1185
 
954
1186
  for (let i = 0; i < 4; i += 1) {
955
1187
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
@@ -1000,8 +1232,8 @@ function ensureFabric() {
1000
1232
  let scale = 1;
1001
1233
  let contentWidth = imageWidth;
1002
1234
  let contentHeight = imageHeight;
1003
- let effectiveWidth = viewport.width;
1004
- let effectiveHeight = viewport.height;
1235
+ let effectiveWidth;
1236
+ let effectiveHeight;
1005
1237
 
1006
1238
  for (let i = 0; i < 4; i += 1) {
1007
1239
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
@@ -1062,26 +1294,36 @@ function ensureFabric() {
1062
1294
  _withNormalizedMaskStyles(callback) {
1063
1295
  if (!this.canvas) return callback();
1064
1296
  const masks = this.canvas.getObjects().filter(object => object.maskId);
1065
- const maskStyleBackups = masks.map(mask => ({
1066
- object: mask,
1067
- stroke: mask.stroke,
1068
- strokeWidth: mask.strokeWidth,
1069
- opacity: mask.opacity
1070
- }));
1297
+ const maskStyleBackups = [];
1071
1298
 
1072
1299
  try {
1073
1300
  masks.forEach(mask => {
1074
- mask.set(this._getMaskNormalStyle(mask));
1301
+ const normalStyle = this._getMaskNormalStyle(mask);
1302
+ const stylePatch = {};
1303
+ Object.keys(normalStyle).forEach(property => {
1304
+ if (mask[property] !== normalStyle[property]) {
1305
+ stylePatch[property] = normalStyle[property];
1306
+ }
1307
+ });
1308
+ const changedProperties = Object.keys(stylePatch);
1309
+ if (!changedProperties.length) return;
1310
+
1311
+ const backup = { object: mask };
1312
+ changedProperties.forEach(property => {
1313
+ backup[property] = mask[property];
1314
+ });
1315
+ maskStyleBackups.push(backup);
1316
+ mask.set(stylePatch);
1075
1317
  });
1076
1318
  return callback();
1077
1319
  } finally {
1078
1320
  maskStyleBackups.forEach(backup => {
1079
1321
  try {
1080
- backup.object.set({
1081
- stroke: backup.stroke,
1082
- strokeWidth: backup.strokeWidth,
1083
- opacity: backup.opacity
1322
+ const restorePatch = {};
1323
+ Object.keys(backup).forEach(property => {
1324
+ if (property !== 'object') restorePatch[property] = backup[property];
1084
1325
  });
1326
+ backup.object.set(restorePatch);
1085
1327
  } catch (error) { void error; }
1086
1328
  });
1087
1329
  }
@@ -1233,6 +1475,7 @@ function ensureFabric() {
1233
1475
  };
1234
1476
  timerId = setTimeout(() => {
1235
1477
  settle(() => reject(new Error('Image crop load timed out')));
1478
+ // Clearing src prevents later network/decode work; already-dispatched onload work is guarded by settle().
1236
1479
  try { imageElement.src = ''; } catch (error) { void error; }
1237
1480
  }, safeTimeoutMs);
1238
1481
  imageElement.onload = () => {
@@ -1260,7 +1503,7 @@ function ensureFabric() {
1260
1503
  }
1261
1504
 
1262
1505
  /**
1263
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1506
+ * Exports a source region directly through Fabric's region export options.
1264
1507
  *
1265
1508
  * @param {Object} region - Canvas source region and export options.
1266
1509
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1273,15 +1516,17 @@ function ensureFabric() {
1273
1516
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1274
1517
  * @private
1275
1518
  */
1276
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1519
+ _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1277
1520
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1278
- const fullDataUrl = this.canvas.toDataURL({
1521
+ return this.canvas.toDataURL({
1279
1522
  format,
1280
1523
  quality,
1281
- multiplier: safeMultiplier
1524
+ multiplier: safeMultiplier,
1525
+ left: sourceX,
1526
+ top: sourceY,
1527
+ width: sourceWidth,
1528
+ height: sourceHeight
1282
1529
  });
1283
-
1284
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1285
1530
  }
1286
1531
 
1287
1532
  /**
@@ -1295,12 +1540,41 @@ function ensureFabric() {
1295
1540
  _getObjectTopLeftPoint(fabricObject) {
1296
1541
  if (!fabricObject) return { x: 0, y: 0 };
1297
1542
  fabricObject.setCoords();
1298
- const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
1299
- if (coords && coords.length) return coords[0];
1300
1543
  const boundingRect = fabricObject.getBoundingRect(true, true);
1301
1544
  return { x: boundingRect.left, y: boundingRect.top };
1302
1545
  }
1303
1546
 
1547
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1548
+ if (!fabricObject) return { x: 0, y: 0 };
1549
+ fabricObject.setCoords();
1550
+ const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
1551
+ if (coords && coords.length) return coords[0];
1552
+ return this._getObjectTopLeftPoint(fabricObject);
1553
+ }
1554
+
1555
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1556
+ if (!fabricObject) return { x: 0, y: 0 };
1557
+ if (typeof fabricObject.getPointByOrigin === 'function') {
1558
+ return fabricObject.getPointByOrigin(originX, originY);
1559
+ }
1560
+ return this._getObjectTopLeftPoint(fabricObject);
1561
+ }
1562
+
1563
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1564
+ if (!fabricObject) return;
1565
+ if (typeof fabricObject.getCenterPoint === 'function' && typeof fabricObject.setPositionByOrigin === 'function') {
1566
+ const center = fabricObject.getCenterPoint();
1567
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1568
+ fabricObject.setPositionByOrigin(nextCenter, 'center', 'center');
1569
+ } else {
1570
+ fabricObject.set({
1571
+ left: (fabricObject.left || 0) + deltaX,
1572
+ top: (fabricObject.top || 0) + deltaY
1573
+ });
1574
+ }
1575
+ fabricObject.setCoords();
1576
+ }
1577
+
1304
1578
  /**
1305
1579
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1306
1580
  *
@@ -1369,8 +1643,10 @@ function ensureFabric() {
1369
1643
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1370
1644
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1371
1645
  try {
1372
- let requiredWidth = this.canvas.getWidth();
1373
- let requiredHeight = this.canvas.getHeight();
1646
+ const currentWidth = this.canvas.getWidth();
1647
+ const currentHeight = this.canvas.getHeight();
1648
+ let requiredWidth = currentWidth;
1649
+ let requiredHeight = currentHeight;
1374
1650
  fabricObjects.forEach(fabricObject => {
1375
1651
  if (!fabricObject) return;
1376
1652
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
@@ -1378,11 +1654,23 @@ function ensureFabric() {
1378
1654
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1379
1655
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1380
1656
  });
1381
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1382
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1383
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1384
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1385
- if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1657
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1658
+
1659
+ let minWidth = 0;
1660
+ let minHeight = 0;
1661
+ if (shouldUseScrollSafeViewport) {
1662
+ const viewport = this._getContainerViewportSize();
1663
+ const safetyMargin = this._getScrollSafetyMargin();
1664
+
1665
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1666
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1667
+ } else if (this.containerElement) {
1668
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1669
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1670
+ }
1671
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1672
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1673
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1386
1674
  this._setCanvasSizeInt(newWidth, newHeight);
1387
1675
  }
1388
1676
  } catch (error) {
@@ -1413,6 +1701,69 @@ function ensureFabric() {
1413
1701
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1414
1702
  }
1415
1703
 
1704
+ _assertIdleForOperation(operationName) {
1705
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1706
+ if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1707
+ throw new Error(`${operationName} cannot run while an animation is running`);
1708
+ }
1709
+ }
1710
+
1711
+ _canMutateNow(operationName) {
1712
+ try {
1713
+ this._assertIdleForOperation(operationName);
1714
+ return true;
1715
+ } catch (error) {
1716
+ this._reportError(`${operationName} blocked`, error);
1717
+ return false;
1718
+ }
1719
+ }
1720
+
1721
+ _rejectActiveAnimations(reason) {
1722
+ const error = reason instanceof Error ? reason : new Error(String(reason || 'Animation cancelled'));
1723
+ this._activeAnimationRejectors.forEach(reject => {
1724
+ try { reject(error); } catch (rejectError) { void rejectError; }
1725
+ });
1726
+ this._activeAnimationRejectors.clear();
1727
+ }
1728
+
1729
+ _animateFabricProperty(fabricObject, property, value) {
1730
+ return new Promise((resolve, reject) => {
1731
+ if (this._disposed || !this.canvas || !fabricObject) {
1732
+ reject(new Error('Animation cannot start after editor disposal'));
1733
+ return;
1734
+ }
1735
+
1736
+ let isSettled = false;
1737
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1738
+ const timeoutMs = Math.max(1000, duration + 1000);
1739
+ let timerId;
1740
+ const settle = (callback) => {
1741
+ if (isSettled) return;
1742
+ isSettled = true;
1743
+ clearTimeout(timerId);
1744
+ this._activeAnimationRejectors.delete(reject);
1745
+ callback();
1746
+ };
1747
+
1748
+ this._activeAnimationRejectors.add(reject);
1749
+ timerId = setTimeout(() => {
1750
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1751
+ }, timeoutMs);
1752
+
1753
+ try {
1754
+ fabricObject.animate(property, value, {
1755
+ duration,
1756
+ onChange: () => {
1757
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1758
+ },
1759
+ onComplete: () => settle(resolve)
1760
+ });
1761
+ } catch (error) {
1762
+ settle(() => reject(error));
1763
+ }
1764
+ });
1765
+ }
1766
+
1416
1767
  /**
1417
1768
  * Scales the original image by a given factor, with animation.
1418
1769
  * Returns a promise that resolves when the scale animation is complete.
@@ -1420,37 +1771,29 @@ function ensureFabric() {
1420
1771
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1421
1772
  * @private
1422
1773
  */
1423
- _scaleImageImpl(factor, options = {}) {
1424
- if (!this.originalImage) return Promise.resolve();
1425
- if (this.isAnimating) return Promise.resolve();
1774
+ async _scaleImageImpl(factor, options = {}) {
1775
+ if (!this.originalImage || this._disposed) return;
1776
+ if (this.isAnimating) return;
1426
1777
  const saveHistory = options.saveHistory !== false;
1427
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1428
- this.currentScale = factor;
1429
- this.isAnimating = true;
1430
- this._updateUI();
1778
+ let didStartAnimation = false;
1779
+ try {
1780
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1781
+ this.currentScale = factor;
1782
+ this.isAnimating = true;
1783
+ didStartAnimation = true;
1784
+ this._updateUI();
1431
1785
 
1432
- const targetScale = this.baseImageScale * factor;
1786
+ const targetScale = this.baseImageScale * factor;
1433
1787
 
1434
- // Scale around current top-left (recompute)
1435
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1436
- this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1788
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1789
+ this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1437
1790
 
1438
- const scaleXAnimation = new Promise((resolve) => {
1439
- this.originalImage.animate('scaleX', targetScale, {
1440
- duration: this.options.animationDuration,
1441
- onChange: this.canvas.renderAll.bind(this.canvas),
1442
- onComplete: resolve
1443
- });
1444
- });
1445
- const scaleYAnimation = new Promise((resolve) => {
1446
- this.originalImage.animate('scaleY', targetScale, {
1447
- duration: this.options.animationDuration,
1448
- onChange: this.canvas.renderAll.bind(this.canvas),
1449
- onComplete: resolve
1450
- });
1451
- });
1791
+ await Promise.all([
1792
+ this._animateFabricProperty(this.originalImage, 'scaleX', targetScale),
1793
+ this._animateFabricProperty(this.originalImage, 'scaleY', targetScale)
1794
+ ]);
1795
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during scale animation');
1452
1796
 
1453
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1454
1797
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1455
1798
  this.originalImage.setCoords();
1456
1799
 
@@ -1460,17 +1803,17 @@ function ensureFabric() {
1460
1803
 
1461
1804
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1462
1805
 
1463
- // Sync mask labels
1464
1806
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1465
1807
 
1466
- this.isAnimating = false;
1467
1808
  this._updateInputs();
1468
- this._updateUI();
1469
1809
  if (saveHistory) this.saveState();
1470
- }).catch(() => {
1471
- this.isAnimating = false;
1472
- this._updateUI();
1473
- });
1810
+ } finally {
1811
+ if (didStartAnimation) {
1812
+ this.isAnimating = false;
1813
+ this._updateInputs();
1814
+ this._updateUI();
1815
+ }
1816
+ }
1474
1817
  }
1475
1818
 
1476
1819
  /**
@@ -1491,27 +1834,29 @@ function ensureFabric() {
1491
1834
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1492
1835
  * @private
1493
1836
  */
1494
- _rotateImageImpl(degrees, options = {}) {
1495
- if (!this.originalImage) return Promise.resolve();
1496
- if (this.isAnimating) return Promise.resolve();
1497
- if (isNaN(degrees)) return Promise.resolve();
1837
+ async _rotateImageImpl(degrees, options = {}) {
1838
+ if (!this.originalImage || this._disposed) return;
1839
+ if (this.isAnimating) return;
1840
+ if (isNaN(degrees)) return;
1498
1841
  const saveHistory = options.saveHistory !== false;
1499
- this.currentRotation = degrees;
1500
- this.isAnimating = true;
1501
- this._updateUI();
1842
+ const image = this.originalImage;
1843
+ const previousOriginX = image.originX || 'left';
1844
+ const previousOriginY = image.originY || 'top';
1845
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1846
+ let didStartAnimation = false;
1847
+ let didCompleteRotation = false;
1848
+ try {
1849
+ this.currentRotation = degrees;
1850
+ this.isAnimating = true;
1851
+ didStartAnimation = true;
1852
+ this._updateUI();
1502
1853
 
1503
- const center = this.originalImage.getCenterPoint();
1504
- this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
1854
+ const center = image.getCenterPoint();
1855
+ this._setObjectOriginKeepingPosition(image, 'center', 'center', center);
1505
1856
 
1506
- const rotationAnimation = new Promise((resolve) => {
1507
- this.originalImage.animate('angle', degrees, {
1508
- duration: this.options.animationDuration,
1509
- onChange: this.canvas.renderAll.bind(this.canvas),
1510
- onComplete: resolve
1511
- });
1512
- });
1857
+ await this._animateFabricProperty(image, 'angle', degrees);
1858
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during rotation animation');
1513
1859
 
1514
- return rotationAnimation.then(() => {
1515
1860
  this.originalImage.set('angle', degrees);
1516
1861
  this.originalImage.setCoords();
1517
1862
 
@@ -1521,20 +1866,24 @@ function ensureFabric() {
1521
1866
 
1522
1867
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1523
1868
 
1524
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1869
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1525
1870
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
1526
1871
 
1527
- // Sync mask labels
1528
1872
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1529
1873
 
1530
- this.isAnimating = false;
1531
1874
  this._updateInputs();
1532
- this._updateUI();
1533
1875
  if (saveHistory) this.saveState();
1534
- }).catch(() => {
1535
- this.isAnimating = false;
1536
- this._updateUI();
1537
- });
1876
+ didCompleteRotation = true;
1877
+ } finally {
1878
+ if (!didCompleteRotation && !this._disposed && image) {
1879
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1880
+ }
1881
+ if (didStartAnimation) {
1882
+ this.isAnimating = false;
1883
+ this._updateInputs();
1884
+ this._updateUI();
1885
+ }
1886
+ }
1538
1887
  }
1539
1888
 
1540
1889
  /**
@@ -1547,13 +1896,14 @@ function ensureFabric() {
1547
1896
  if (!this.originalImage) return Promise.resolve();
1548
1897
 
1549
1898
  return this.animationQueue.add(async () => {
1550
- const before = this._lastSnapshot || this._serializeCanvasState();
1899
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
1551
1900
  await this._scaleImageImpl(1, { saveHistory: false });
1552
1901
  await this._rotateImageImpl(0, { saveHistory: false });
1553
- const after = this._serializeCanvasState();
1902
+ const after = this._captureCanvasStateOrThrow('resetImageTransform');
1554
1903
  this._pushStateTransition(before, after);
1555
1904
  }).catch(error => {
1556
1905
  this._reportError('resetImageTransform() failed', error);
1906
+ throw error;
1557
1907
  });
1558
1908
  }
1559
1909
 
@@ -1575,17 +1925,35 @@ function ensureFabric() {
1575
1925
  * @public
1576
1926
  */
1577
1927
  loadFromState(serializedState) {
1578
- if (!serializedState || !this.canvas) return Promise.resolve();
1928
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1929
+ if (this._cropMode || this._cropRect) {
1930
+ this._removeCropRect();
1931
+ this._restoreCropObjectState();
1932
+ this._cropMode = false;
1933
+ if (this._prevSelectionSetting !== undefined && this.canvas) {
1934
+ this.canvas.selection = !!this._prevSelectionSetting;
1935
+ }
1936
+ this._prevSelectionSetting = undefined;
1937
+ }
1579
1938
 
1580
- return new Promise((resolve) => {
1939
+ return new Promise((resolve, reject) => {
1581
1940
  try {
1582
1941
  const state = (typeof serializedState === 'string')
1583
1942
  ? JSON.parse(serializedState)
1584
1943
  : serializedState;
1585
1944
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1586
1945
 
1587
- this.canvas.loadFromJSON(state, () => {
1946
+ this.canvas.loadFromJSON(state, async () => {
1588
1947
  try {
1948
+ if (this._disposed || !this.canvas) {
1949
+ reject(new Error('Editor was disposed while loading state'));
1950
+ return;
1951
+ }
1952
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1953
+ if (this._disposed || !this.canvas) {
1954
+ reject(new Error('Editor was disposed while loading state'));
1955
+ return;
1956
+ }
1589
1957
  this._hideAllMaskLabels();
1590
1958
  const canvasObjects = this.canvas.getObjects();
1591
1959
  this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
@@ -1644,20 +2012,48 @@ function ensureFabric() {
1644
2012
  this._updatePlaceholderStatus();
1645
2013
  this._lastSnapshot = this._serializeCanvasState();
1646
2014
  this._updateUI();
2015
+ resolve();
1647
2016
  } catch (callbackError) {
1648
2017
  this._reportError('loadFromState() failed', callbackError);
1649
- } finally {
1650
- resolve();
2018
+ reject(callbackError);
1651
2019
  }
1652
2020
  });
1653
2021
 
1654
2022
  } catch (error) {
1655
2023
  this._reportError('loadFromState() failed', error);
1656
- resolve();
2024
+ reject(error);
1657
2025
  }
1658
2026
  });
1659
2027
  }
1660
2028
 
2029
+ async _waitForFabricImagesReady(canvasObjects) {
2030
+ const imageObjects = (canvasObjects || []).filter(object => object && object.type === 'image');
2031
+ await Promise.all(imageObjects.map(object => this._waitForImageElementReady(
2032
+ typeof object.getElement === 'function' ? object.getElement() : object._element
2033
+ )));
2034
+ }
2035
+
2036
+ _waitForImageElementReady(imageElement) {
2037
+ if (!imageElement) return Promise.resolve();
2038
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
2039
+ return new Promise((resolve, reject) => {
2040
+ let isSettled = false;
2041
+ const timerId = setTimeout(() => {
2042
+ settle(() => reject(new Error('Image load timed out while restoring state')));
2043
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2044
+ const settle = (callback) => {
2045
+ if (isSettled) return;
2046
+ isSettled = true;
2047
+ clearTimeout(timerId);
2048
+ imageElement.onload = null;
2049
+ imageElement.onerror = null;
2050
+ callback();
2051
+ };
2052
+ imageElement.onload = () => settle(resolve);
2053
+ imageElement.onerror = (error) => settle(() => reject(error));
2054
+ });
2055
+ }
2056
+
1661
2057
  /**
1662
2058
  * Saves the current editable canvas state as an undoable history transition.
1663
2059
  *
@@ -1669,10 +2065,9 @@ function ensureFabric() {
1669
2065
  */
1670
2066
  saveState() {
1671
2067
  if (!this.canvas) return;
1672
- const activeObject = this.canvas.getActiveObject();
1673
2068
 
1674
2069
  try {
1675
- const after = this._serializeCanvasState();
2070
+ const after = this._captureCanvasStateOrThrow('saveState');
1676
2071
  const before = this._lastSnapshot || after;
1677
2072
  if (after === before) return;
1678
2073
  let executedOnce = false;
@@ -1693,9 +2088,6 @@ function ensureFabric() {
1693
2088
  } catch (error) {
1694
2089
  this._reportWarning('saveState: failed to save canvas snapshot', error);
1695
2090
  } finally {
1696
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1697
- this._handleSelectionChanged([activeObject]);
1698
- }
1699
2091
  this._updateUI();
1700
2092
  }
1701
2093
  }
@@ -1712,7 +2104,10 @@ function ensureFabric() {
1712
2104
  * @private
1713
2105
  */
1714
2106
  _pushStateTransition(before, after) {
1715
- if (!before || !after) return;
2107
+ if (!before || !after) {
2108
+ this._reportWarning('History transition skipped because a canvas snapshot is unavailable');
2109
+ return;
2110
+ }
1716
2111
  if (before === after) return;
1717
2112
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1718
2113
 
@@ -1734,7 +2129,10 @@ function ensureFabric() {
1734
2129
  undo() {
1735
2130
  return this.historyManager.undo()
1736
2131
  .then(() => { this._updateUI(); })
1737
- .catch(error => { this._reportError('undo failed', error); });
2132
+ .catch(error => {
2133
+ this._reportError('undo failed', error);
2134
+ throw error;
2135
+ });
1738
2136
  }
1739
2137
 
1740
2138
  /**
@@ -1746,7 +2144,10 @@ function ensureFabric() {
1746
2144
  redo() {
1747
2145
  return this.historyManager.redo()
1748
2146
  .then(() => { this._updateUI(); })
1749
- .catch(error => { this._reportError('redo failed', error); });
2147
+ .catch(error => {
2148
+ this._reportError('redo failed', error);
2149
+ throw error;
2150
+ });
1750
2151
  }
1751
2152
 
1752
2153
  _rebindMaskEvents(mask) {
@@ -1768,23 +2169,17 @@ function ensureFabric() {
1768
2169
  }
1769
2170
  if (Object.keys(metadata).length) mask.set(metadata);
1770
2171
 
1771
- const normalStyle = {
1772
- stroke: mask.originalStroke || '#ccc',
1773
- strokeWidth: mask.originalStrokeWidth,
1774
- opacity: mask.originalAlpha
1775
- };
1776
- const hoverStyle = {
1777
- stroke: '#ff5500',
1778
- strokeWidth: 2,
1779
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1780
- };
1781
-
1782
2172
  const mouseover = () => {
1783
- mask.set(hoverStyle);
2173
+ const opacity = Number(mask.originalAlpha);
2174
+ mask.set({
2175
+ stroke: '#ff5500',
2176
+ strokeWidth: 2,
2177
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
2178
+ });
1784
2179
  if (mask.canvas) mask.canvas.requestRenderAll();
1785
2180
  };
1786
2181
  const mouseout = () => {
1787
- mask.set(normalStyle);
2182
+ mask.set(this._getMaskNormalStyle(mask));
1788
2183
  if (mask.canvas) mask.canvas.requestRenderAll();
1789
2184
  };
1790
2185
 
@@ -1823,6 +2218,7 @@ function ensureFabric() {
1823
2218
  */
1824
2219
  createMask(config = {}) {
1825
2220
  if (!this.canvas) return null;
2221
+ if (!this._canMutateNow('createMask')) return null;
1826
2222
  const shapeType = config.shape || 'rect';
1827
2223
  // Normalize mask defaults before applying caller-provided overrides.
1828
2224
  const maskConfig = {
@@ -1841,37 +2237,43 @@ function ensureFabric() {
1841
2237
 
1842
2238
  // Always start placement relative to canvas left/top.
1843
2239
  const firstOffset = 10;
1844
- let left = firstOffset;
1845
- let top = firstOffset;
2240
+ let left;
2241
+ let top;
2242
+
2243
+ const getCanvasBasis = (axis) => {
2244
+ const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
2245
+ const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
2246
+ if (axis === 'height') return canvasHeight;
2247
+ if (axis === 'min') return Math.min(canvasWidth, canvasHeight);
2248
+ return canvasWidth;
2249
+ };
1846
2250
 
1847
- const resolveValue = (value, fallback) => {
2251
+ const resolveValue = (value, fallback, axis = 'width') => {
1848
2252
  if (typeof value === 'function')
1849
2253
  return value(this.canvas, this.options);
1850
2254
  if (typeof value === 'string' && value.endsWith('%')) {
1851
- const percent = parseFloat(value) / 100;
1852
- return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
2255
+ const percent = Number.parseFloat(value) / 100;
2256
+ if (!Number.isFinite(percent)) return fallback;
2257
+ return Math.floor(getCanvasBasis(axis) * percent);
1853
2258
  }
1854
2259
  return value != null ? value : fallback;
1855
- }
2260
+ };
1856
2261
 
1857
2262
  if (maskConfig.left === undefined && this._lastMask) {
1858
2263
  const previousMask = this._lastMask;
1859
- let previousMaskRight = previousMask.left;
1860
-
1861
- if (previousMask.getScaledWidth) {
1862
- previousMaskRight += previousMask.getScaledWidth();
1863
- } else if (previousMask.width) {
1864
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1865
- }
1866
- left = Math.round(previousMaskRight + maskConfig.gap);
1867
- top = previousMask.top ?? firstOffset;
2264
+ if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
2265
+ const previousBounds = typeof previousMask.getBoundingRect === 'function'
2266
+ ? previousMask.getBoundingRect(true, true)
2267
+ : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2268
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2269
+ top = Math.round(previousBounds.top ?? firstOffset);
1868
2270
  } else {
1869
- left = resolveValue(maskConfig.left, firstOffset);
1870
- top = resolveValue(maskConfig.top, firstOffset);
2271
+ left = resolveValue(maskConfig.left, firstOffset, 'width');
2272
+ top = resolveValue(maskConfig.top, firstOffset, 'height');
1871
2273
  }
1872
2274
 
1873
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1874
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
2275
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
2276
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
1875
2277
  maskConfig.left = left;
1876
2278
  maskConfig.top = top;
1877
2279
 
@@ -1883,7 +2285,7 @@ function ensureFabric() {
1883
2285
  case 'circle':
1884
2286
  mask = new fabric.Circle({
1885
2287
  left, top,
1886
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
2288
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
1887
2289
  fill: maskConfig.color,
1888
2290
  opacity: maskConfig.alpha,
1889
2291
  angle: maskConfig.angle,
@@ -1893,8 +2295,8 @@ function ensureFabric() {
1893
2295
  case 'ellipse':
1894
2296
  mask = new fabric.Ellipse({
1895
2297
  left, top,
1896
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1897
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
2298
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
2299
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
1898
2300
  fill: maskConfig.color,
1899
2301
  opacity: maskConfig.alpha,
1900
2302
  angle: maskConfig.angle,
@@ -1922,8 +2324,8 @@ function ensureFabric() {
1922
2324
  default:
1923
2325
  mask = new fabric.Rect({
1924
2326
  left, top,
1925
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1926
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
2327
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
2328
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
1927
2329
  fill: maskConfig.color,
1928
2330
  opacity: maskConfig.alpha,
1929
2331
  angle: maskConfig.angle,
@@ -1964,7 +2366,7 @@ function ensureFabric() {
1964
2366
  // Store placement values so the next mask can be positioned beside this one.
1965
2367
  this._lastMaskInitialLeft = left;
1966
2368
  this._lastMaskInitialTop = top;
1967
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
2369
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
1968
2370
 
1969
2371
  const maskId = ++this.maskCounter;
1970
2372
  mask.set({
@@ -2002,6 +2404,8 @@ function ensureFabric() {
2002
2404
  * The associated label is also removed. UI and mask list are updated.
2003
2405
  */
2004
2406
  removeSelectedMask() {
2407
+ if (!this.canvas) return;
2408
+ if (!this._canMutateNow('removeSelectedMask')) return;
2005
2409
  const activeObject = this.canvas.getActiveObject();
2006
2410
  const selectedMasks = this._getModifiedMasks(activeObject);
2007
2411
  if (!selectedMasks.length) return;
@@ -2030,6 +2434,8 @@ function ensureFabric() {
2030
2434
  * UI and internal mask placement memory are reset.
2031
2435
  */
2032
2436
  removeAllMasks(options = {}) {
2437
+ if (!this.canvas) return;
2438
+ if (!this._canMutateNow('removeAllMasks')) return;
2033
2439
  const saveHistory = options.saveHistory !== false;
2034
2440
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2035
2441
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -2095,6 +2501,10 @@ function ensureFabric() {
2095
2501
  let textObject = null;
2096
2502
  if (this.options.label && typeof this.options.label.create === 'function') {
2097
2503
  textObject = this.options.label.create(mask, fabric);
2504
+ if (!textObject || typeof textObject.set !== 'function') {
2505
+ this._reportWarning('label.create() returned an invalid Fabric object; using the default label');
2506
+ textObject = null;
2507
+ }
2098
2508
  }
2099
2509
  if (!textObject) {
2100
2510
  let labelText = mask.maskName;
@@ -2161,10 +2571,11 @@ function ensureFabric() {
2161
2571
  if (!this.options.maskLabelOnSelect) return;
2162
2572
  if (!mask.__label) return;
2163
2573
 
2164
- const coords = mask.getCoords ? mask.getCoords() : null;
2165
- if (!coords || coords.length < 4) return;
2574
+ if (typeof mask.setCoords === 'function') mask.setCoords();
2575
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2576
+ if (!bounds) return;
2166
2577
 
2167
- const tl = coords[0];
2578
+ const tl = { x: bounds.left, y: bounds.top };
2168
2579
  const center = mask.getCenterPoint();
2169
2580
 
2170
2581
  const vx = center.x - tl.x;
@@ -2247,7 +2658,7 @@ function ensureFabric() {
2247
2658
  * @private
2248
2659
  */
2249
2660
  _updateMaskList() {
2250
- const maskListElement = document.getElementById(this.elements.maskList);
2661
+ const maskListElement = this._getElement('maskList');
2251
2662
  if (!maskListElement) return;
2252
2663
  maskListElement.innerHTML = '';
2253
2664
  const masks = this.canvas.getObjects().filter(object => object.maskId);
@@ -2255,11 +2666,22 @@ function ensureFabric() {
2255
2666
  const listItemElement = document.createElement('li');
2256
2667
  listItemElement.className = 'list-group-item mask-item';
2257
2668
  listItemElement.textContent = mask.maskName;
2258
- listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
2669
+ listItemElement.dataset.maskId = String(mask.maskId);
2259
2670
  maskListElement.appendChild(listItemElement);
2260
2671
  });
2261
2672
  }
2262
2673
 
2674
+ _handleMaskListClick(event) {
2675
+ if (!this.canvas) return;
2676
+ const itemElement = event.target && event.target.closest ? event.target.closest('.mask-item') : null;
2677
+ if (!itemElement || !itemElement.dataset) return;
2678
+ const maskId = Number(itemElement.dataset.maskId);
2679
+ const mask = this.canvas.getObjects().find(object => Number(object.maskId) === maskId);
2680
+ if (!mask) return;
2681
+ this.canvas.setActiveObject(mask);
2682
+ this._handleSelectionChanged([mask]);
2683
+ }
2684
+
2263
2685
  /**
2264
2686
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
2265
2687
  *
@@ -2267,12 +2689,13 @@ function ensureFabric() {
2267
2689
  * @private
2268
2690
  */
2269
2691
  _updateMaskListSelection(selectedMask) {
2270
- const maskListElement = document.getElementById(this.elements.maskList);
2692
+ const maskListElement = this._getElement('maskList');
2271
2693
  if (!maskListElement) return;
2272
2694
  const maskItems = maskListElement.querySelectorAll('.mask-item');
2273
2695
  maskItems.forEach(item => {
2274
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2696
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
2275
2697
  item.classList.toggle('active', isSelected);
2698
+ item.classList.toggle('selected', isSelected);
2276
2699
  });
2277
2700
  }
2278
2701
 
@@ -2288,6 +2711,7 @@ function ensureFabric() {
2288
2711
  */
2289
2712
  async mergeMasks() {
2290
2713
  if (!this.originalImage) return;
2714
+ this._assertIdleForOperation('mergeMasks');
2291
2715
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2292
2716
  if (!masks.length) return;
2293
2717
 
@@ -2298,11 +2722,12 @@ function ensureFabric() {
2298
2722
  const beforeJson = this._serializeCanvasState();
2299
2723
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2300
2724
  this.removeAllMasks({ saveHistory: false });
2301
- await this.loadImage(merged, { preserveScroll: true });
2725
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2302
2726
  const afterJson = this._serializeCanvasState();
2303
2727
  this._pushStateTransition(beforeJson, afterJson);
2304
2728
  } catch (error) {
2305
2729
  this._reportError('merge error', error);
2730
+ throw error;
2306
2731
  }
2307
2732
  }
2308
2733
 
@@ -2326,6 +2751,7 @@ function ensureFabric() {
2326
2751
  */
2327
2752
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2328
2753
  if (!this.originalImage) return;
2754
+ if (!this._canMutateNow('downloadImage')) return;
2329
2755
  const exportImageArea = this.options.exportImageAreaByDefault;
2330
2756
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
2331
2757
  .then(imageBase64 => {
@@ -2357,6 +2783,7 @@ function ensureFabric() {
2357
2783
  */
2358
2784
  async exportImageBase64(options = {}) {
2359
2785
  if (!this.originalImage) throw new Error('No image loaded');
2786
+ this._assertIdleForOperation('exportImageBase64');
2360
2787
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2361
2788
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2362
2789
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2374,7 +2801,7 @@ function ensureFabric() {
2374
2801
  this.originalImage.setCoords();
2375
2802
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2376
2803
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2377
- return await this._exportCanvasRegionToDataURL({
2804
+ return this._exportCanvasRegionToDataURL({
2378
2805
  ...exportRegion,
2379
2806
  multiplier,
2380
2807
  quality,
@@ -2420,7 +2847,7 @@ function ensureFabric() {
2420
2847
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2421
2848
 
2422
2849
  // Crop precisely in offscreen canvas
2423
- finalBase64 = await this._exportCanvasRegionToDataURL({
2850
+ finalBase64 = this._exportCanvasRegionToDataURL({
2424
2851
  ...exportRegion,
2425
2852
  multiplier,
2426
2853
  quality,
@@ -2478,6 +2905,7 @@ function ensureFabric() {
2478
2905
  */
2479
2906
  async exportImageFile(options = {}) {
2480
2907
  if (!this.originalImage) throw new Error('No image loaded');
2908
+ this._assertIdleForOperation('exportImageFile');
2481
2909
  const {
2482
2910
  mergeMask = true,
2483
2911
  fileType = 'jpeg',
@@ -2519,6 +2947,7 @@ function ensureFabric() {
2519
2947
  offscreenCanvas.width = imageElement.width;
2520
2948
  offscreenCanvas.height = imageElement.height;
2521
2949
  const context = offscreenCanvas.getContext('2d');
2950
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
2522
2951
  context.drawImage(imageElement, 0, 0);
2523
2952
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2524
2953
  resolve(convertedDataUrl);
@@ -2591,13 +3020,15 @@ function ensureFabric() {
2591
3020
  if (this._cropHandlers && this._cropHandlers.length) {
2592
3021
  this._cropHandlers.forEach(targetHandlers => {
2593
3022
  targetHandlers.handlers.forEach(handlerRecord => {
2594
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3023
+ if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
3024
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3025
+ }
2595
3026
  });
2596
3027
  });
2597
3028
  }
2598
3029
  } catch (error) { void error; }
2599
3030
 
2600
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3031
+ try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
2601
3032
  this._cropRect = null;
2602
3033
  this._cropHandlers = [];
2603
3034
  }
@@ -2613,7 +3044,9 @@ function ensureFabric() {
2613
3044
  */
2614
3045
  enterCropMode() {
2615
3046
  if (!this.canvas || !this.originalImage || this._cropMode) return;
3047
+ if (!this._canMutateNow('enterCropMode')) return;
2616
3048
  if (!this.isImageLoaded()) return;
3049
+ this._removeCropRect();
2617
3050
  this._cropMode = true;
2618
3051
 
2619
3052
  // Disable group selection so only the crop rectangle can be manipulated.
@@ -2747,17 +3180,18 @@ function ensureFabric() {
2747
3180
  */
2748
3181
  async applyCrop() {
2749
3182
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3183
+ this._assertIdleForOperation('applyCrop');
2750
3184
 
2751
3185
  // Fabric does not update control coordinates automatically after programmatic transforms.
2752
3186
  this._cropRect.setCoords();
2753
3187
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2754
3188
 
2755
- const cropRegion = this._getClampedCanvasRegion(rectBounds);
3189
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
2756
3190
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2757
3191
 
2758
3192
  this._restoreCropObjectState();
2759
3193
 
2760
- let beforeJson = null;
3194
+ let beforeJson;
2761
3195
  try {
2762
3196
  beforeJson = this._serializeCanvasState();
2763
3197
  } catch (error) {
@@ -2782,12 +3216,8 @@ function ensureFabric() {
2782
3216
  this._removeLabelForMask(mask);
2783
3217
  this.canvas.remove(mask);
2784
3218
  if (shouldPreserveMasks && intersectsCrop) {
2785
- mask.set({
2786
- left: (mask.left || 0) - cropRegion.sourceX,
2787
- top: (mask.top || 0) - cropRegion.sourceY,
2788
- visible: true
2789
- });
2790
- mask.setCoords();
3219
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3220
+ mask.set({ visible: true });
2791
3221
  preservedMasks.push(mask);
2792
3222
  }
2793
3223
  } catch (error) {
@@ -2825,7 +3255,7 @@ function ensureFabric() {
2825
3255
 
2826
3256
  // Load the cropped image as the new base image.
2827
3257
  try {
2828
- await this.loadImage(croppedBase64);
3258
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2829
3259
  if (preservedMasks.length) {
2830
3260
  preservedMasks.forEach(mask => {
2831
3261
  this._rebindMaskEvents(mask);
@@ -2843,9 +3273,9 @@ function ensureFabric() {
2843
3273
  }
2844
3274
 
2845
3275
  // Create an after snapshot and push one history command for the crop operation.
2846
- let afterJson = null;
3276
+ let afterJson;
2847
3277
  try {
2848
- afterJson = this._serializeCanvasState();
3278
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2849
3279
  } catch (error) {
2850
3280
  this._reportWarning('applyCrop: failed to serialize after state', error);
2851
3281
  afterJson = null;
@@ -2871,7 +3301,7 @@ function ensureFabric() {
2871
3301
  * @private
2872
3302
  */
2873
3303
  _updateInputs() {
2874
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
3304
+ const scaleInputElement = this._getElement('scaleRate');
2875
3305
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2876
3306
  }
2877
3307
 
@@ -2881,6 +3311,7 @@ function ensureFabric() {
2881
3311
  * @private
2882
3312
  */
2883
3313
  _updateUI() {
3314
+ if (!this.canvas) return;
2884
3315
  const hasImage = !!this.originalImage;
2885
3316
  const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
2886
3317
  const hasMasks = masks.length > 0;
@@ -2894,7 +3325,7 @@ function ensureFabric() {
2894
3325
  if (isInCropMode) {
2895
3326
  // Disable all controls except the crop action buttons while crop mode is active.
2896
3327
  for (const key of Object.keys(this.elements || {})) {
2897
- const element = document.getElementById(this.elements[key]);
3328
+ const element = this._getElement(key);
2898
3329
  if (!element) continue;
2899
3330
  if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
2900
3331
  this._setDisabled(key, false);
@@ -2932,7 +3363,7 @@ function ensureFabric() {
2932
3363
  * @private
2933
3364
  */
2934
3365
  _setDisabled(key, disabled) {
2935
- const element = document.getElementById(this.elements[key]);
3366
+ const element = this._getElement(key);
2936
3367
  if (!element) return;
2937
3368
  if ('disabled' in element) {
2938
3369
  element.disabled = !!disabled;
@@ -2970,16 +3401,66 @@ function ensureFabric() {
2970
3401
  * @private
2971
3402
  */
2972
3403
  _setPlaceholderVisible(show) {
2973
- if (!this.placeholderElement || !this.containerElement) return;
2974
- if (show) {
2975
- this.placeholderElement.classList.remove('d-none');
2976
- this.placeholderElement.classList.add('d-flex');
2977
- this.containerElement.classList.add('d-none');
3404
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
3405
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
3406
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
3407
+ this._setElementVisible(canvasVisibilityElement, !show);
3408
+ }
3409
+ }
3410
+
3411
+ _getCanvasVisibilityElement() {
3412
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
3413
+ if (
3414
+ this.containerElement &&
3415
+ this.placeholderElement &&
3416
+ (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))
3417
+ ) {
3418
+ return wrapperElement || this.canvasElement;
3419
+ }
3420
+ return this.containerElement || wrapperElement || this.canvasElement;
3421
+ }
3422
+
3423
+ /**
3424
+ * Updates element visibility.
3425
+ *
3426
+ * @param {HTMLElement} element - Element whose visibility should be updated.
3427
+ * @param {boolean} isVisible - If true, removes the hidden state.
3428
+ * @returns {void}
3429
+ * @private
3430
+ */
3431
+ _setElementVisible(element, isVisible) {
3432
+ if (!element) return;
3433
+ this._rememberElementVisibility(element);
3434
+ element.hidden = !isVisible;
3435
+ element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
3436
+ if (element.classList) {
3437
+ element.classList.toggle('d-none', !isVisible);
3438
+ }
3439
+ }
3440
+
3441
+ _rememberElementVisibility(element) {
3442
+ if (!element || this._visibilityStateByElement.has(element)) return;
3443
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3444
+ }
3445
+
3446
+ _captureElementVisibility(element) {
3447
+ if (!element) return null;
3448
+ return {
3449
+ hidden: element.hidden,
3450
+ ariaHidden: element.getAttribute('aria-hidden'),
3451
+ className: element.className
3452
+ };
3453
+ }
3454
+
3455
+ _restoreElementVisibility(element, state) {
3456
+ if (!element || !state) return;
3457
+ element.hidden = !!state.hidden;
3458
+ if (state.ariaHidden === null) {
3459
+ element.removeAttribute('aria-hidden');
2978
3460
  } else {
2979
- this.placeholderElement.classList.remove('d-flex');
2980
- this.placeholderElement.classList.add('d-none');
2981
- this.containerElement.classList.remove('d-none');
3461
+ element.setAttribute('aria-hidden', state.ariaHidden);
2982
3462
  }
3463
+ element.className = state.className || '';
2983
3464
  }
2984
3465
 
2985
3466
  /**
@@ -2988,11 +3469,16 @@ function ensureFabric() {
2988
3469
  * @public
2989
3470
  */
2990
3471
  dispose() {
3472
+ this._disposed = true;
3473
+ this._rejectActiveAnimations(new Error('Editor disposed during animation'));
3474
+ if (this.animationQueue) {
3475
+ this.animationQueue.cancelAll(new Error('Editor disposed'));
3476
+ }
3477
+
2991
3478
  // Remove bound DOM event listeners
2992
3479
  try {
2993
- for (const key in (this._handlersByElementKey || {})) {
2994
- const handlers = this._handlersByElementKey[key] || [];
2995
- const element = document.getElementById(this.elements[key]);
3480
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3481
+ const element = this._getElement(key);
2996
3482
  if (!element) continue;
2997
3483
  handlers.forEach(handlerRecord => {
2998
3484
  try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
@@ -3005,8 +3491,25 @@ function ensureFabric() {
3005
3491
  this._cropRect = null;
3006
3492
  }
3007
3493
 
3008
- if (this.containerElement && this._containerOriginalOverflow !== undefined) {
3009
- try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
3494
+ if (this.containerElement && this._containerOriginalOverflow) {
3495
+ try { this._restoreContainerOverflowState(); } catch (error) { void error; }
3496
+ }
3497
+
3498
+ if (this._visibilityStateByElement) {
3499
+ try {
3500
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach(element => {
3501
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3502
+ if (state) this._restoreElementVisibility(element, state);
3503
+ });
3504
+ } catch (error) { void error; }
3505
+ }
3506
+
3507
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
3508
+ try {
3509
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3510
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3511
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3512
+ } catch (error) { void error; }
3010
3513
  }
3011
3514
 
3012
3515
  if (this.canvas) {
@@ -3016,6 +3519,19 @@ function ensureFabric() {
3016
3519
  this.isImageLoadedToCanvas = false;
3017
3520
  }
3018
3521
  this._handlersByElementKey = {};
3522
+ this._elementCache = {};
3523
+ this._clearMaskPlacementMemory();
3524
+ this.originalImage = null;
3525
+ this.baseImageScale = 1;
3526
+ this.currentScale = 1;
3527
+ this.currentRotation = 0;
3528
+ this.isAnimating = false;
3529
+ this._cropMode = false;
3530
+ this._cropRect = null;
3531
+ this._cropHandlers = [];
3532
+ this._cropPrevEvented = null;
3533
+ this._prevSelectionSetting = undefined;
3534
+ this._initialized = false;
3019
3535
  }
3020
3536
  }
3021
3537
 
@@ -3068,6 +3584,7 @@ function ensureFabric() {
3068
3584
  * @type {boolean}
3069
3585
  */
3070
3586
  this.isRunning = false;
3587
+ this.currentTask = null;
3071
3588
  }
3072
3589
 
3073
3590
  /**
@@ -3078,13 +3595,32 @@ function ensureFabric() {
3078
3595
  */
3079
3596
  async add(animationFn) {
3080
3597
  return new Promise((resolve, reject) => {
3081
- this.animationTasks.push({ animationFn, resolve, reject });
3598
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
3082
3599
  if (!this.isRunning) {
3083
3600
  this._drainQueue();
3084
3601
  }
3085
3602
  });
3086
3603
  }
3087
3604
 
3605
+ isBusy() {
3606
+ return this.isRunning || this.animationTasks.length > 0;
3607
+ }
3608
+
3609
+ cancelAll(reason = new Error('Animation queue cancelled')) {
3610
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3611
+ const tasks = [
3612
+ ...(this.currentTask ? [this.currentTask] : []),
3613
+ ...this.animationTasks.splice(0)
3614
+ ];
3615
+ tasks.forEach(task => {
3616
+ if (!task || task.isSettled) return;
3617
+ task.isSettled = true;
3618
+ task.reject(cancellationError);
3619
+ });
3620
+ this.isRunning = false;
3621
+ this.currentTask = null;
3622
+ }
3623
+
3088
3624
  /**
3089
3625
  * Runs queued animation tasks sequentially until the queue is empty.
3090
3626
  *
@@ -3092,22 +3628,30 @@ function ensureFabric() {
3092
3628
  * @returns {Promise<void>}
3093
3629
  */
3094
3630
  async _drainQueue() {
3095
- if (this.animationTasks.length === 0) {
3096
- this.isRunning = false;
3097
- return;
3098
- }
3099
-
3631
+ if (this.isRunning) return;
3100
3632
  this.isRunning = true;
3101
- const { animationFn, resolve, reject } = this.animationTasks.shift();
3102
3633
 
3103
- try {
3104
- const result = await animationFn();
3105
- resolve(result);
3106
- } catch (error) {
3107
- reject(error);
3634
+ while (this.animationTasks.length > 0) {
3635
+ const task = this.animationTasks.shift();
3636
+ this.currentTask = task;
3637
+
3638
+ try {
3639
+ const result = await task.animationFn();
3640
+ if (!task.isSettled) {
3641
+ task.isSettled = true;
3642
+ task.resolve(result);
3643
+ }
3644
+ } catch (error) {
3645
+ if (!task.isSettled) {
3646
+ task.isSettled = true;
3647
+ task.reject(error);
3648
+ }
3649
+ } finally {
3650
+ if (this.currentTask === task) this.currentTask = null;
3651
+ }
3108
3652
  }
3109
3653
 
3110
- await this._drainQueue();
3654
+ this.isRunning = false;
3111
3655
  }
3112
3656
  }
3113
3657
 
@@ -3163,16 +3707,8 @@ function ensureFabric() {
3163
3707
  * @private
3164
3708
  */
3165
3709
  enqueue(task) {
3166
- const nextTask = this.pending.then(task, task);
3167
- let pendingAfterTask;
3168
- const resetPending = () => {
3169
- if (this.pending === pendingAfterTask) {
3170
- this.pending = Promise.resolve();
3171
- }
3172
- };
3173
-
3174
- pendingAfterTask = nextTask.then(resetPending, resetPending);
3175
- this.pending = pendingAfterTask;
3710
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3711
+ this.pending = nextTask.catch(() => undefined);
3176
3712
  return nextTask;
3177
3713
  }
3178
3714
 
@@ -3184,8 +3720,14 @@ function ensureFabric() {
3184
3720
  * @returns {void}
3185
3721
  */
3186
3722
  execute(command) {
3187
- command.execute();
3723
+ const result = command.execute();
3724
+ if (result && typeof result.then === 'function') {
3725
+ return Promise.resolve(result).then(() => {
3726
+ this.push(command);
3727
+ });
3728
+ }
3188
3729
  this.push(command);
3730
+ return result;
3189
3731
  }
3190
3732
 
3191
3733
  /**
@@ -3205,9 +3747,8 @@ function ensureFabric() {
3205
3747
 
3206
3748
  if (this.history.length > this.maxSize) {
3207
3749
  this.history.shift();
3208
- } else {
3209
- this.currentIndex++;
3210
3750
  }
3751
+ this.currentIndex = this.history.length - 1;
3211
3752
  }
3212
3753
 
3213
3754
  /**