@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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.3.1
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,8 +1071,14 @@ function ensureFabric() {
868
1071
  };
869
1072
  }
870
1073
 
871
- let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
872
- let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
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
+ }
873
1082
 
874
1083
  if (this._hasFixedContainerScrollbars()) {
875
1084
  return { width, height };
@@ -1266,6 +1475,7 @@ function ensureFabric() {
1266
1475
  };
1267
1476
  timerId = setTimeout(() => {
1268
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().
1269
1479
  try { imageElement.src = ''; } catch (error) { void error; }
1270
1480
  }, safeTimeoutMs);
1271
1481
  imageElement.onload = () => {
@@ -1293,7 +1503,7 @@ function ensureFabric() {
1293
1503
  }
1294
1504
 
1295
1505
  /**
1296
- * 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.
1297
1507
  *
1298
1508
  * @param {Object} region - Canvas source region and export options.
1299
1509
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1306,15 +1516,17 @@ function ensureFabric() {
1306
1516
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1307
1517
  * @private
1308
1518
  */
1309
- 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' }) {
1310
1520
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1311
- const fullDataUrl = this.canvas.toDataURL({
1521
+ return this.canvas.toDataURL({
1312
1522
  format,
1313
1523
  quality,
1314
- multiplier: safeMultiplier
1524
+ multiplier: safeMultiplier,
1525
+ left: sourceX,
1526
+ top: sourceY,
1527
+ width: sourceWidth,
1528
+ height: sourceHeight
1315
1529
  });
1316
-
1317
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1318
1530
  }
1319
1531
 
1320
1532
  /**
@@ -1328,12 +1540,41 @@ function ensureFabric() {
1328
1540
  _getObjectTopLeftPoint(fabricObject) {
1329
1541
  if (!fabricObject) return { x: 0, y: 0 };
1330
1542
  fabricObject.setCoords();
1331
- const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
1332
- if (coords && coords.length) return coords[0];
1333
1543
  const boundingRect = fabricObject.getBoundingRect(true, true);
1334
1544
  return { x: boundingRect.left, y: boundingRect.top };
1335
1545
  }
1336
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
+
1337
1578
  /**
1338
1579
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1339
1580
  *
@@ -1402,8 +1643,10 @@ function ensureFabric() {
1402
1643
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1403
1644
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1404
1645
  try {
1405
- let requiredWidth = this.canvas.getWidth();
1406
- 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;
1407
1650
  fabricObjects.forEach(fabricObject => {
1408
1651
  if (!fabricObject) return;
1409
1652
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
@@ -1411,11 +1654,23 @@ function ensureFabric() {
1411
1654
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1412
1655
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1413
1656
  });
1414
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1415
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1416
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1417
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1418
- 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) {
1419
1674
  this._setCanvasSizeInt(newWidth, newHeight);
1420
1675
  }
1421
1676
  } catch (error) {
@@ -1446,6 +1701,69 @@ function ensureFabric() {
1446
1701
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1447
1702
  }
1448
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
+
1449
1767
  /**
1450
1768
  * Scales the original image by a given factor, with animation.
1451
1769
  * Returns a promise that resolves when the scale animation is complete.
@@ -1453,37 +1771,29 @@ function ensureFabric() {
1453
1771
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1454
1772
  * @private
1455
1773
  */
1456
- _scaleImageImpl(factor, options = {}) {
1457
- if (!this.originalImage) return Promise.resolve();
1458
- if (this.isAnimating) return Promise.resolve();
1774
+ async _scaleImageImpl(factor, options = {}) {
1775
+ if (!this.originalImage || this._disposed) return;
1776
+ if (this.isAnimating) return;
1459
1777
  const saveHistory = options.saveHistory !== false;
1460
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1461
- this.currentScale = factor;
1462
- this.isAnimating = true;
1463
- 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();
1464
1785
 
1465
- const targetScale = this.baseImageScale * factor;
1786
+ const targetScale = this.baseImageScale * factor;
1466
1787
 
1467
- // Scale around current top-left (recompute)
1468
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1469
- this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1788
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1789
+ this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1470
1790
 
1471
- const scaleXAnimation = new Promise((resolve) => {
1472
- this.originalImage.animate('scaleX', targetScale, {
1473
- duration: this.options.animationDuration,
1474
- onChange: this.canvas.renderAll.bind(this.canvas),
1475
- onComplete: resolve
1476
- });
1477
- });
1478
- const scaleYAnimation = new Promise((resolve) => {
1479
- this.originalImage.animate('scaleY', targetScale, {
1480
- duration: this.options.animationDuration,
1481
- onChange: this.canvas.renderAll.bind(this.canvas),
1482
- onComplete: resolve
1483
- });
1484
- });
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');
1485
1796
 
1486
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1487
1797
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1488
1798
  this.originalImage.setCoords();
1489
1799
 
@@ -1493,17 +1803,17 @@ function ensureFabric() {
1493
1803
 
1494
1804
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1495
1805
 
1496
- // Sync mask labels
1497
1806
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1498
1807
 
1499
- this.isAnimating = false;
1500
1808
  this._updateInputs();
1501
- this._updateUI();
1502
1809
  if (saveHistory) this.saveState();
1503
- }).catch(() => {
1504
- this.isAnimating = false;
1505
- this._updateUI();
1506
- });
1810
+ } finally {
1811
+ if (didStartAnimation) {
1812
+ this.isAnimating = false;
1813
+ this._updateInputs();
1814
+ this._updateUI();
1815
+ }
1816
+ }
1507
1817
  }
1508
1818
 
1509
1819
  /**
@@ -1524,27 +1834,29 @@ function ensureFabric() {
1524
1834
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1525
1835
  * @private
1526
1836
  */
1527
- _rotateImageImpl(degrees, options = {}) {
1528
- if (!this.originalImage) return Promise.resolve();
1529
- if (this.isAnimating) return Promise.resolve();
1530
- 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;
1531
1841
  const saveHistory = options.saveHistory !== false;
1532
- this.currentRotation = degrees;
1533
- this.isAnimating = true;
1534
- 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();
1535
1853
 
1536
- const center = this.originalImage.getCenterPoint();
1537
- this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
1854
+ const center = image.getCenterPoint();
1855
+ this._setObjectOriginKeepingPosition(image, 'center', 'center', center);
1538
1856
 
1539
- const rotationAnimation = new Promise((resolve) => {
1540
- this.originalImage.animate('angle', degrees, {
1541
- duration: this.options.animationDuration,
1542
- onChange: this.canvas.renderAll.bind(this.canvas),
1543
- onComplete: resolve
1544
- });
1545
- });
1857
+ await this._animateFabricProperty(image, 'angle', degrees);
1858
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during rotation animation');
1546
1859
 
1547
- return rotationAnimation.then(() => {
1548
1860
  this.originalImage.set('angle', degrees);
1549
1861
  this.originalImage.setCoords();
1550
1862
 
@@ -1554,20 +1866,24 @@ function ensureFabric() {
1554
1866
 
1555
1867
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1556
1868
 
1557
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1869
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1558
1870
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
1559
1871
 
1560
- // Sync mask labels
1561
1872
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1562
1873
 
1563
- this.isAnimating = false;
1564
1874
  this._updateInputs();
1565
- this._updateUI();
1566
1875
  if (saveHistory) this.saveState();
1567
- }).catch(() => {
1568
- this.isAnimating = false;
1569
- this._updateUI();
1570
- });
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
+ }
1571
1887
  }
1572
1888
 
1573
1889
  /**
@@ -1580,13 +1896,14 @@ function ensureFabric() {
1580
1896
  if (!this.originalImage) return Promise.resolve();
1581
1897
 
1582
1898
  return this.animationQueue.add(async () => {
1583
- const before = this._lastSnapshot || this._serializeCanvasState();
1899
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
1584
1900
  await this._scaleImageImpl(1, { saveHistory: false });
1585
1901
  await this._rotateImageImpl(0, { saveHistory: false });
1586
- const after = this._serializeCanvasState();
1902
+ const after = this._captureCanvasStateOrThrow('resetImageTransform');
1587
1903
  this._pushStateTransition(before, after);
1588
1904
  }).catch(error => {
1589
1905
  this._reportError('resetImageTransform() failed', error);
1906
+ throw error;
1590
1907
  });
1591
1908
  }
1592
1909
 
@@ -1608,17 +1925,35 @@ function ensureFabric() {
1608
1925
  * @public
1609
1926
  */
1610
1927
  loadFromState(serializedState) {
1611
- 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
+ }
1612
1938
 
1613
- return new Promise((resolve) => {
1939
+ return new Promise((resolve, reject) => {
1614
1940
  try {
1615
1941
  const state = (typeof serializedState === 'string')
1616
1942
  ? JSON.parse(serializedState)
1617
1943
  : serializedState;
1618
1944
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1619
1945
 
1620
- this.canvas.loadFromJSON(state, () => {
1946
+ this.canvas.loadFromJSON(state, async () => {
1621
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
+ }
1622
1957
  this._hideAllMaskLabels();
1623
1958
  const canvasObjects = this.canvas.getObjects();
1624
1959
  this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
@@ -1677,20 +2012,48 @@ function ensureFabric() {
1677
2012
  this._updatePlaceholderStatus();
1678
2013
  this._lastSnapshot = this._serializeCanvasState();
1679
2014
  this._updateUI();
2015
+ resolve();
1680
2016
  } catch (callbackError) {
1681
2017
  this._reportError('loadFromState() failed', callbackError);
1682
- } finally {
1683
- resolve();
2018
+ reject(callbackError);
1684
2019
  }
1685
2020
  });
1686
2021
 
1687
2022
  } catch (error) {
1688
2023
  this._reportError('loadFromState() failed', error);
1689
- resolve();
2024
+ reject(error);
1690
2025
  }
1691
2026
  });
1692
2027
  }
1693
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
+
1694
2057
  /**
1695
2058
  * Saves the current editable canvas state as an undoable history transition.
1696
2059
  *
@@ -1702,10 +2065,9 @@ function ensureFabric() {
1702
2065
  */
1703
2066
  saveState() {
1704
2067
  if (!this.canvas) return;
1705
- const activeObject = this.canvas.getActiveObject();
1706
2068
 
1707
2069
  try {
1708
- const after = this._serializeCanvasState();
2070
+ const after = this._captureCanvasStateOrThrow('saveState');
1709
2071
  const before = this._lastSnapshot || after;
1710
2072
  if (after === before) return;
1711
2073
  let executedOnce = false;
@@ -1726,9 +2088,6 @@ function ensureFabric() {
1726
2088
  } catch (error) {
1727
2089
  this._reportWarning('saveState: failed to save canvas snapshot', error);
1728
2090
  } finally {
1729
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1730
- this._handleSelectionChanged([activeObject]);
1731
- }
1732
2091
  this._updateUI();
1733
2092
  }
1734
2093
  }
@@ -1745,7 +2104,10 @@ function ensureFabric() {
1745
2104
  * @private
1746
2105
  */
1747
2106
  _pushStateTransition(before, after) {
1748
- if (!before || !after) return;
2107
+ if (!before || !after) {
2108
+ this._reportWarning('History transition skipped because a canvas snapshot is unavailable');
2109
+ return;
2110
+ }
1749
2111
  if (before === after) return;
1750
2112
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1751
2113
 
@@ -1767,7 +2129,10 @@ function ensureFabric() {
1767
2129
  undo() {
1768
2130
  return this.historyManager.undo()
1769
2131
  .then(() => { this._updateUI(); })
1770
- .catch(error => { this._reportError('undo failed', error); });
2132
+ .catch(error => {
2133
+ this._reportError('undo failed', error);
2134
+ throw error;
2135
+ });
1771
2136
  }
1772
2137
 
1773
2138
  /**
@@ -1779,7 +2144,10 @@ function ensureFabric() {
1779
2144
  redo() {
1780
2145
  return this.historyManager.redo()
1781
2146
  .then(() => { this._updateUI(); })
1782
- .catch(error => { this._reportError('redo failed', error); });
2147
+ .catch(error => {
2148
+ this._reportError('redo failed', error);
2149
+ throw error;
2150
+ });
1783
2151
  }
1784
2152
 
1785
2153
  _rebindMaskEvents(mask) {
@@ -1801,23 +2169,17 @@ function ensureFabric() {
1801
2169
  }
1802
2170
  if (Object.keys(metadata).length) mask.set(metadata);
1803
2171
 
1804
- const normalStyle = {
1805
- stroke: mask.originalStroke || '#ccc',
1806
- strokeWidth: mask.originalStrokeWidth,
1807
- opacity: mask.originalAlpha
1808
- };
1809
- const hoverStyle = {
1810
- stroke: '#ff5500',
1811
- strokeWidth: 2,
1812
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1813
- };
1814
-
1815
2172
  const mouseover = () => {
1816
- 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
+ });
1817
2179
  if (mask.canvas) mask.canvas.requestRenderAll();
1818
2180
  };
1819
2181
  const mouseout = () => {
1820
- mask.set(normalStyle);
2182
+ mask.set(this._getMaskNormalStyle(mask));
1821
2183
  if (mask.canvas) mask.canvas.requestRenderAll();
1822
2184
  };
1823
2185
 
@@ -1856,6 +2218,7 @@ function ensureFabric() {
1856
2218
  */
1857
2219
  createMask(config = {}) {
1858
2220
  if (!this.canvas) return null;
2221
+ if (!this._canMutateNow('createMask')) return null;
1859
2222
  const shapeType = config.shape || 'rect';
1860
2223
  // Normalize mask defaults before applying caller-provided overrides.
1861
2224
  const maskConfig = {
@@ -1898,15 +2261,12 @@ function ensureFabric() {
1898
2261
 
1899
2262
  if (maskConfig.left === undefined && this._lastMask) {
1900
2263
  const previousMask = this._lastMask;
1901
- let previousMaskRight = previousMask.left;
1902
-
1903
- if (previousMask.getScaledWidth) {
1904
- previousMaskRight += previousMask.getScaledWidth();
1905
- } else if (previousMask.width) {
1906
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1907
- }
1908
- left = Math.round(previousMaskRight + maskConfig.gap);
1909
- 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);
1910
2270
  } else {
1911
2271
  left = resolveValue(maskConfig.left, firstOffset, 'width');
1912
2272
  top = resolveValue(maskConfig.top, firstOffset, 'height');
@@ -2044,6 +2404,8 @@ function ensureFabric() {
2044
2404
  * The associated label is also removed. UI and mask list are updated.
2045
2405
  */
2046
2406
  removeSelectedMask() {
2407
+ if (!this.canvas) return;
2408
+ if (!this._canMutateNow('removeSelectedMask')) return;
2047
2409
  const activeObject = this.canvas.getActiveObject();
2048
2410
  const selectedMasks = this._getModifiedMasks(activeObject);
2049
2411
  if (!selectedMasks.length) return;
@@ -2072,6 +2434,8 @@ function ensureFabric() {
2072
2434
  * UI and internal mask placement memory are reset.
2073
2435
  */
2074
2436
  removeAllMasks(options = {}) {
2437
+ if (!this.canvas) return;
2438
+ if (!this._canMutateNow('removeAllMasks')) return;
2075
2439
  const saveHistory = options.saveHistory !== false;
2076
2440
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2077
2441
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -2137,6 +2501,10 @@ function ensureFabric() {
2137
2501
  let textObject = null;
2138
2502
  if (this.options.label && typeof this.options.label.create === 'function') {
2139
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
+ }
2140
2508
  }
2141
2509
  if (!textObject) {
2142
2510
  let labelText = mask.maskName;
@@ -2203,10 +2571,11 @@ function ensureFabric() {
2203
2571
  if (!this.options.maskLabelOnSelect) return;
2204
2572
  if (!mask.__label) return;
2205
2573
 
2206
- const coords = mask.getCoords ? mask.getCoords() : null;
2207
- 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;
2208
2577
 
2209
- const tl = coords[0];
2578
+ const tl = { x: bounds.left, y: bounds.top };
2210
2579
  const center = mask.getCenterPoint();
2211
2580
 
2212
2581
  const vx = center.x - tl.x;
@@ -2289,7 +2658,7 @@ function ensureFabric() {
2289
2658
  * @private
2290
2659
  */
2291
2660
  _updateMaskList() {
2292
- const maskListElement = document.getElementById(this.elements.maskList);
2661
+ const maskListElement = this._getElement('maskList');
2293
2662
  if (!maskListElement) return;
2294
2663
  maskListElement.innerHTML = '';
2295
2664
  const masks = this.canvas.getObjects().filter(object => object.maskId);
@@ -2297,11 +2666,22 @@ function ensureFabric() {
2297
2666
  const listItemElement = document.createElement('li');
2298
2667
  listItemElement.className = 'list-group-item mask-item';
2299
2668
  listItemElement.textContent = mask.maskName;
2300
- listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
2669
+ listItemElement.dataset.maskId = String(mask.maskId);
2301
2670
  maskListElement.appendChild(listItemElement);
2302
2671
  });
2303
2672
  }
2304
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
+
2305
2685
  /**
2306
2686
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
2307
2687
  *
@@ -2309,12 +2689,13 @@ function ensureFabric() {
2309
2689
  * @private
2310
2690
  */
2311
2691
  _updateMaskListSelection(selectedMask) {
2312
- const maskListElement = document.getElementById(this.elements.maskList);
2692
+ const maskListElement = this._getElement('maskList');
2313
2693
  if (!maskListElement) return;
2314
2694
  const maskItems = maskListElement.querySelectorAll('.mask-item');
2315
2695
  maskItems.forEach(item => {
2316
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2696
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
2317
2697
  item.classList.toggle('active', isSelected);
2698
+ item.classList.toggle('selected', isSelected);
2318
2699
  });
2319
2700
  }
2320
2701
 
@@ -2330,6 +2711,7 @@ function ensureFabric() {
2330
2711
  */
2331
2712
  async mergeMasks() {
2332
2713
  if (!this.originalImage) return;
2714
+ this._assertIdleForOperation('mergeMasks');
2333
2715
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2334
2716
  if (!masks.length) return;
2335
2717
 
@@ -2340,11 +2722,12 @@ function ensureFabric() {
2340
2722
  const beforeJson = this._serializeCanvasState();
2341
2723
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2342
2724
  this.removeAllMasks({ saveHistory: false });
2343
- await this.loadImage(merged, { preserveScroll: true });
2725
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2344
2726
  const afterJson = this._serializeCanvasState();
2345
2727
  this._pushStateTransition(beforeJson, afterJson);
2346
2728
  } catch (error) {
2347
2729
  this._reportError('merge error', error);
2730
+ throw error;
2348
2731
  }
2349
2732
  }
2350
2733
 
@@ -2368,6 +2751,7 @@ function ensureFabric() {
2368
2751
  */
2369
2752
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2370
2753
  if (!this.originalImage) return;
2754
+ if (!this._canMutateNow('downloadImage')) return;
2371
2755
  const exportImageArea = this.options.exportImageAreaByDefault;
2372
2756
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
2373
2757
  .then(imageBase64 => {
@@ -2399,6 +2783,7 @@ function ensureFabric() {
2399
2783
  */
2400
2784
  async exportImageBase64(options = {}) {
2401
2785
  if (!this.originalImage) throw new Error('No image loaded');
2786
+ this._assertIdleForOperation('exportImageBase64');
2402
2787
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2403
2788
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2404
2789
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2416,7 +2801,7 @@ function ensureFabric() {
2416
2801
  this.originalImage.setCoords();
2417
2802
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2418
2803
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2419
- return await this._exportCanvasRegionToDataURL({
2804
+ return this._exportCanvasRegionToDataURL({
2420
2805
  ...exportRegion,
2421
2806
  multiplier,
2422
2807
  quality,
@@ -2462,7 +2847,7 @@ function ensureFabric() {
2462
2847
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2463
2848
 
2464
2849
  // Crop precisely in offscreen canvas
2465
- finalBase64 = await this._exportCanvasRegionToDataURL({
2850
+ finalBase64 = this._exportCanvasRegionToDataURL({
2466
2851
  ...exportRegion,
2467
2852
  multiplier,
2468
2853
  quality,
@@ -2520,6 +2905,7 @@ function ensureFabric() {
2520
2905
  */
2521
2906
  async exportImageFile(options = {}) {
2522
2907
  if (!this.originalImage) throw new Error('No image loaded');
2908
+ this._assertIdleForOperation('exportImageFile');
2523
2909
  const {
2524
2910
  mergeMask = true,
2525
2911
  fileType = 'jpeg',
@@ -2561,6 +2947,7 @@ function ensureFabric() {
2561
2947
  offscreenCanvas.width = imageElement.width;
2562
2948
  offscreenCanvas.height = imageElement.height;
2563
2949
  const context = offscreenCanvas.getContext('2d');
2950
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
2564
2951
  context.drawImage(imageElement, 0, 0);
2565
2952
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2566
2953
  resolve(convertedDataUrl);
@@ -2633,13 +3020,15 @@ function ensureFabric() {
2633
3020
  if (this._cropHandlers && this._cropHandlers.length) {
2634
3021
  this._cropHandlers.forEach(targetHandlers => {
2635
3022
  targetHandlers.handlers.forEach(handlerRecord => {
2636
- 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
+ }
2637
3026
  });
2638
3027
  });
2639
3028
  }
2640
3029
  } catch (error) { void error; }
2641
3030
 
2642
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3031
+ try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
2643
3032
  this._cropRect = null;
2644
3033
  this._cropHandlers = [];
2645
3034
  }
@@ -2655,7 +3044,9 @@ function ensureFabric() {
2655
3044
  */
2656
3045
  enterCropMode() {
2657
3046
  if (!this.canvas || !this.originalImage || this._cropMode) return;
3047
+ if (!this._canMutateNow('enterCropMode')) return;
2658
3048
  if (!this.isImageLoaded()) return;
3049
+ this._removeCropRect();
2659
3050
  this._cropMode = true;
2660
3051
 
2661
3052
  // Disable group selection so only the crop rectangle can be manipulated.
@@ -2789,6 +3180,7 @@ function ensureFabric() {
2789
3180
  */
2790
3181
  async applyCrop() {
2791
3182
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3183
+ this._assertIdleForOperation('applyCrop');
2792
3184
 
2793
3185
  // Fabric does not update control coordinates automatically after programmatic transforms.
2794
3186
  this._cropRect.setCoords();
@@ -2824,12 +3216,8 @@ function ensureFabric() {
2824
3216
  this._removeLabelForMask(mask);
2825
3217
  this.canvas.remove(mask);
2826
3218
  if (shouldPreserveMasks && intersectsCrop) {
2827
- mask.set({
2828
- left: (mask.left || 0) - cropRegion.sourceX,
2829
- top: (mask.top || 0) - cropRegion.sourceY,
2830
- visible: true
2831
- });
2832
- mask.setCoords();
3219
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3220
+ mask.set({ visible: true });
2833
3221
  preservedMasks.push(mask);
2834
3222
  }
2835
3223
  } catch (error) {
@@ -2867,7 +3255,7 @@ function ensureFabric() {
2867
3255
 
2868
3256
  // Load the cropped image as the new base image.
2869
3257
  try {
2870
- await this.loadImage(croppedBase64);
3258
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2871
3259
  if (preservedMasks.length) {
2872
3260
  preservedMasks.forEach(mask => {
2873
3261
  this._rebindMaskEvents(mask);
@@ -2887,7 +3275,7 @@ function ensureFabric() {
2887
3275
  // Create an after snapshot and push one history command for the crop operation.
2888
3276
  let afterJson;
2889
3277
  try {
2890
- afterJson = this._serializeCanvasState();
3278
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2891
3279
  } catch (error) {
2892
3280
  this._reportWarning('applyCrop: failed to serialize after state', error);
2893
3281
  afterJson = null;
@@ -2913,7 +3301,7 @@ function ensureFabric() {
2913
3301
  * @private
2914
3302
  */
2915
3303
  _updateInputs() {
2916
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
3304
+ const scaleInputElement = this._getElement('scaleRate');
2917
3305
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2918
3306
  }
2919
3307
 
@@ -2923,6 +3311,7 @@ function ensureFabric() {
2923
3311
  * @private
2924
3312
  */
2925
3313
  _updateUI() {
3314
+ if (!this.canvas) return;
2926
3315
  const hasImage = !!this.originalImage;
2927
3316
  const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
2928
3317
  const hasMasks = masks.length > 0;
@@ -2936,7 +3325,7 @@ function ensureFabric() {
2936
3325
  if (isInCropMode) {
2937
3326
  // Disable all controls except the crop action buttons while crop mode is active.
2938
3327
  for (const key of Object.keys(this.elements || {})) {
2939
- const element = document.getElementById(this.elements[key]);
3328
+ const element = this._getElement(key);
2940
3329
  if (!element) continue;
2941
3330
  if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
2942
3331
  this._setDisabled(key, false);
@@ -2974,7 +3363,7 @@ function ensureFabric() {
2974
3363
  * @private
2975
3364
  */
2976
3365
  _setDisabled(key, disabled) {
2977
- const element = document.getElementById(this.elements[key]);
3366
+ const element = this._getElement(key);
2978
3367
  if (!element) return;
2979
3368
  if ('disabled' in element) {
2980
3369
  element.disabled = !!disabled;
@@ -3012,9 +3401,23 @@ function ensureFabric() {
3012
3401
  * @private
3013
3402
  */
3014
3403
  _setPlaceholderVisible(show) {
3015
- if (!this.placeholderElement || !this.containerElement) return;
3016
- this._setElementVisible(this.placeholderElement, show);
3017
- this._setElementVisible(this.containerElement, !show);
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;
3018
3421
  }
3019
3422
 
3020
3423
  /**
@@ -3027,9 +3430,37 @@ function ensureFabric() {
3027
3430
  */
3028
3431
  _setElementVisible(element, isVisible) {
3029
3432
  if (!element) return;
3433
+ this._rememberElementVisibility(element);
3030
3434
  element.hidden = !isVisible;
3031
3435
  element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
3032
- if (isVisible && element.classList) element.classList.remove('d-none');
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');
3460
+ } else {
3461
+ element.setAttribute('aria-hidden', state.ariaHidden);
3462
+ }
3463
+ element.className = state.className || '';
3033
3464
  }
3034
3465
 
3035
3466
  /**
@@ -3038,11 +3469,16 @@ function ensureFabric() {
3038
3469
  * @public
3039
3470
  */
3040
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
+
3041
3478
  // Remove bound DOM event listeners
3042
3479
  try {
3043
- for (const key in (this._handlersByElementKey || {})) {
3044
- const handlers = this._handlersByElementKey[key] || [];
3045
- const element = document.getElementById(this.elements[key]);
3480
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3481
+ const element = this._getElement(key);
3046
3482
  if (!element) continue;
3047
3483
  handlers.forEach(handlerRecord => {
3048
3484
  try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
@@ -3055,8 +3491,25 @@ function ensureFabric() {
3055
3491
  this._cropRect = null;
3056
3492
  }
3057
3493
 
3058
- if (this.containerElement && this._containerOriginalOverflow !== undefined) {
3059
- 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; }
3060
3513
  }
3061
3514
 
3062
3515
  if (this.canvas) {
@@ -3066,6 +3519,19 @@ function ensureFabric() {
3066
3519
  this.isImageLoadedToCanvas = false;
3067
3520
  }
3068
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;
3069
3535
  }
3070
3536
  }
3071
3537
 
@@ -3118,6 +3584,7 @@ function ensureFabric() {
3118
3584
  * @type {boolean}
3119
3585
  */
3120
3586
  this.isRunning = false;
3587
+ this.currentTask = null;
3121
3588
  }
3122
3589
 
3123
3590
  /**
@@ -3128,13 +3595,32 @@ function ensureFabric() {
3128
3595
  */
3129
3596
  async add(animationFn) {
3130
3597
  return new Promise((resolve, reject) => {
3131
- this.animationTasks.push({ animationFn, resolve, reject });
3598
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
3132
3599
  if (!this.isRunning) {
3133
3600
  this._drainQueue();
3134
3601
  }
3135
3602
  });
3136
3603
  }
3137
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
+
3138
3624
  /**
3139
3625
  * Runs queued animation tasks sequentially until the queue is empty.
3140
3626
  *
@@ -3142,22 +3628,30 @@ function ensureFabric() {
3142
3628
  * @returns {Promise<void>}
3143
3629
  */
3144
3630
  async _drainQueue() {
3145
- if (this.animationTasks.length === 0) {
3146
- this.isRunning = false;
3147
- return;
3148
- }
3149
-
3631
+ if (this.isRunning) return;
3150
3632
  this.isRunning = true;
3151
- const { animationFn, resolve, reject } = this.animationTasks.shift();
3152
3633
 
3153
- try {
3154
- const result = await animationFn();
3155
- resolve(result);
3156
- } catch (error) {
3157
- 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
+ }
3158
3652
  }
3159
3653
 
3160
- await this._drainQueue();
3654
+ this.isRunning = false;
3161
3655
  }
3162
3656
  }
3163
3657
 
@@ -3213,16 +3707,8 @@ function ensureFabric() {
3213
3707
  * @private
3214
3708
  */
3215
3709
  enqueue(task) {
3216
- const nextTask = this.pending.then(task, task);
3217
- let pendingAfterTask;
3218
- const resetPending = () => {
3219
- if (this.pending === pendingAfterTask) {
3220
- this.pending = Promise.resolve();
3221
- }
3222
- };
3223
-
3224
- pendingAfterTask = nextTask.then(resetPending, resetPending);
3225
- this.pending = pendingAfterTask;
3710
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3711
+ this.pending = nextTask.catch(() => undefined);
3226
3712
  return nextTask;
3227
3713
  }
3228
3714
 
@@ -3234,8 +3720,14 @@ function ensureFabric() {
3234
3720
  * @returns {void}
3235
3721
  */
3236
3722
  execute(command) {
3237
- 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
+ }
3238
3729
  this.push(command);
3730
+ return result;
3239
3731
  }
3240
3732
 
3241
3733
  /**
@@ -3255,9 +3747,8 @@ function ensureFabric() {
3255
3747
 
3256
3748
  if (this.history.length > this.maxSize) {
3257
3749
  this.history.shift();
3258
- } else {
3259
- this.currentIndex++;
3260
3750
  }
3751
+ this.currentIndex = this.history.length - 1;
3261
3752
  }
3262
3753
 
3263
3754
  /**