@bensitu/image-editor 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.3.1
4
+ * @version 1.4.1
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
8
8
  */
9
9
 
10
10
  let fabric = null;
11
+ const INTERNAL_OPERATION_TOKEN = Symbol('ImageEditorInternalOperation');
11
12
 
12
13
  /**
13
14
  * Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
@@ -196,6 +197,8 @@ function ensureFabric() {
196
197
  downsampleMaxWidth: 4000,
197
198
  downsampleMaxHeight: 3000,
198
199
  downsampleQuality: 0.92,
200
+ preserveSourceFormat: true,
201
+ downsampleMimeType: null,
199
202
  imageLoadTimeoutMs: 30000,
200
203
 
201
204
  exportMultiplier: 1,
@@ -250,11 +253,16 @@ function ensureFabric() {
250
253
  this.currentRotation = 0;
251
254
  this.maskCounter = 0;
252
255
  this.isAnimating = false;
256
+ this._isLoading = false;
257
+ this._activeOperationName = null;
258
+ this._activeOperationToken = null;
253
259
  this.elements = {};
254
260
  this.isImageLoadedToCanvas = false;
255
261
  this.maxHistorySize = 50;
256
262
 
257
263
  this._handlersByElementKey = {};
264
+ this._elementCache = {};
265
+ this._elementOriginalPointerEvents = new Map();
258
266
 
259
267
  this._lastMask = null;
260
268
  this._lastMaskInitialLeft = null;
@@ -267,8 +275,14 @@ function ensureFabric() {
267
275
  this._cropHandlers = [];
268
276
  this._cropPrevEvented = null;
269
277
  this._prevSelectionSetting = undefined;
270
- this._containerOriginalOverflow = undefined;
278
+ this._containerOriginalOverflow = null;
279
+ this._lastContainerViewportSize = null;
280
+ this._canvasElementOriginalStyle = null;
281
+ this._visibilityStateByElement = new WeakMap();
271
282
  this._scrollbarSizeCache = null;
283
+ this._activeAnimationRejectors = new Set();
284
+ this._disposed = false;
285
+ this._initialized = false;
272
286
 
273
287
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
274
288
 
@@ -341,6 +355,20 @@ function ensureFabric() {
341
355
  */
342
356
  init(idMap = {}) {
343
357
  if (!this._fabricLoaded) return;
358
+ if (this._initialized || this.canvas) this.dispose();
359
+ this._disposed = false;
360
+ this._initialized = true;
361
+ this.animationQueue = new AnimationQueue();
362
+ this.historyManager = new HistoryManager(this.maxHistorySize);
363
+ this._visibilityStateByElement = new WeakMap();
364
+ this._activeAnimationRejectors = new Set();
365
+ this._isLoading = false;
366
+ this._activeOperationName = null;
367
+ this._activeOperationToken = null;
368
+ this._elementOriginalPointerEvents = new Map();
369
+ this._containerOriginalOverflow = null;
370
+ this._lastContainerViewportSize = null;
371
+ this._canvasElementOriginalStyle = null;
344
372
 
345
373
  const defaults = {
346
374
  canvas: 'fabricCanvas',
@@ -369,6 +397,7 @@ function ensureFabric() {
369
397
  };
370
398
 
371
399
  this.elements = { ...defaults, ...idMap };
400
+ this._elementCache = {};
372
401
 
373
402
  this._initCanvas();
374
403
  this._bindEvents();
@@ -413,19 +442,25 @@ function ensureFabric() {
413
442
  * @private
414
443
  */
415
444
  _initCanvas() {
416
- const canvasElement = document.getElementById(this.elements.canvas);
445
+ const canvasElement = this._getElement('canvas');
417
446
  if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
418
447
  this.canvasElement = canvasElement;
448
+ this._canvasElementOriginalStyle = {
449
+ display: canvasElement.style.display || '',
450
+ width: canvasElement.style.width || '',
451
+ height: canvasElement.style.height || '',
452
+ maxWidth: canvasElement.style.maxWidth || ''
453
+ };
419
454
 
420
455
  // Decide which element acts as the viewport for size fallback and scrolling.
421
456
  if (this.elements.canvasContainer) {
422
- const containerElement = document.getElementById(this.elements.canvasContainer);
457
+ const containerElement = this._getElement('canvasContainer');
423
458
  this.containerElement = containerElement || canvasElement.parentElement;
424
459
  } else {
425
460
  this.containerElement = canvasElement.parentElement;
426
461
  }
427
462
 
428
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
463
+ this.placeholderElement = this._getElement('imgPlaceholder') || null;
429
464
 
430
465
  // Prefer a measured container size when it is available.
431
466
  let initialWidth = this.options.canvasWidth;
@@ -436,6 +471,11 @@ function ensureFabric() {
436
471
  if (containerWidth > 0 && containerHeight > 0) {
437
472
  initialWidth = containerWidth;
438
473
  initialHeight = containerHeight;
474
+
475
+ this._lastContainerViewportSize = {
476
+ width: containerWidth,
477
+ height: containerHeight
478
+ };
439
479
  }
440
480
  }
441
481
 
@@ -460,6 +500,24 @@ function ensureFabric() {
460
500
  this.canvasElement.style.display = 'block';
461
501
  }
462
502
 
503
+ /**
504
+ * Returns a configured DOM element and caches lookups for hot UI paths.
505
+ *
506
+ * @param {string} key - Key in the configured element map.
507
+ * @returns {HTMLElement|null} The configured element, or null when missing.
508
+ * @private
509
+ */
510
+ _getElement(key) {
511
+ const id = this.elements && this.elements[key];
512
+ if (!id) return null;
513
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
514
+ return this._elementCache[key];
515
+ }
516
+ const element = document.getElementById(id);
517
+ if (this._elementCache) this._elementCache[key] = element || null;
518
+ return element || null;
519
+ }
520
+
463
521
  /**
464
522
  * Records a history entry after Fabric finishes modifying one or more masks.
465
523
  *
@@ -504,9 +562,7 @@ function ensureFabric() {
504
562
  */
505
563
  _syncContainerOverflow(options = {}) {
506
564
  if (!this.containerElement || !this.containerElement.style) return;
507
- if (this._containerOriginalOverflow === undefined) {
508
- this._containerOriginalOverflow = this.containerElement.style.overflow || '';
509
- }
565
+ this._captureContainerOverflowState();
510
566
 
511
567
  const shouldPreserveScroll = options.preserveScroll === true;
512
568
  if (this.options.coverImageToCanvas) {
@@ -522,10 +578,33 @@ function ensureFabric() {
522
578
  this.containerElement.scrollTop = 0;
523
579
  }
524
580
  } else {
525
- this.containerElement.style.overflow = this._containerOriginalOverflow;
581
+ this._restoreContainerOverflowState();
526
582
  }
527
583
  }
528
584
 
585
+ _captureContainerOverflowState() {
586
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
587
+ this._containerOriginalOverflow = {
588
+ overflow: this.containerElement.style.overflow || '',
589
+ overflowX: this.containerElement.style.overflowX || '',
590
+ overflowY: this.containerElement.style.overflowY || ''
591
+ };
592
+ }
593
+
594
+ _restoreContainerOverflowState() {
595
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
596
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
597
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
598
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
599
+ }
600
+
601
+ _restoreContainerOverflowSnapshot(snapshot) {
602
+ if (!this.containerElement || !this.containerElement.style || !snapshot) return;
603
+ this.containerElement.style.overflow = snapshot.overflow || '';
604
+ this.containerElement.style.overflowX = snapshot.overflowX || '';
605
+ this.containerElement.style.overflowY = snapshot.overflowY || '';
606
+ }
607
+
529
608
  /**
530
609
  * DOM / UI bindings
531
610
  * @private
@@ -533,54 +612,61 @@ function ensureFabric() {
533
612
  _bindEvents() {
534
613
  // Click anywhere on the upload area opens the native file dialog
535
614
  this._bindIfExists('uploadArea', 'click', () => {
536
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
615
+ const uploadAreaElement = this._getElement('uploadArea');
537
616
  if (this._isElementDisabled(uploadAreaElement)) return;
538
- document.getElementById(this.elements.imageInput)?.click();
617
+ this._getElement('imageInput')?.click();
539
618
  });
540
619
  // File-input change
541
620
  this._bindIfExists('imageInput', 'change', (event) => {
542
621
  const file = event.target.files && event.target.files[0];
543
- if (file) this._loadImageFile(file);
622
+ if (file) {
623
+ this._loadImageFile(file)
624
+ .catch(error => this._reportError('Image file could not be loaded', error))
625
+ .finally(() => {
626
+ event.target.value = '';
627
+ });
628
+ }
544
629
  });
545
630
  // 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(); });
631
+ this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
632
+ this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
633
+ this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
549
634
  // Mask management
550
635
  this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
551
636
  this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
552
637
  this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
553
638
  // Merge + download
554
- this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
639
+ this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
555
640
  this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
556
641
  // Undo + Redo
557
- this._bindIfExists('undoBtn', 'click', () => this.undo());
558
- this._bindIfExists('redoBtn', 'click', () => this.redo());
642
+ this._bindIfExists('undoBtn', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
643
+ this._bindIfExists('redoBtn', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
559
644
 
560
645
  // Rotation buttons (step can be overridden by two input fields)
561
646
  this._bindIfExists('rotateLeftBtn', 'click', () => {
562
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
647
+ const rotationInputElement = this._getElement('rotationLeftInput');
563
648
  let step = this.options.rotationStep;
564
649
  if (rotationInputElement) {
565
650
  const parsedStep = parseFloat(rotationInputElement.value);
566
651
  if (!isNaN(parsedStep)) step = parsedStep;
567
652
  }
568
- this.rotateImage(this.currentRotation - step);
653
+ this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
569
654
  });
570
655
  this._bindIfExists('rotateRightBtn', 'click', () => {
571
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
656
+ const rotationInputElement = this._getElement('rotationRightInput');
572
657
  let step = this.options.rotationStep;
573
658
  if (rotationInputElement) {
574
659
  const parsedStep = parseFloat(rotationInputElement.value);
575
660
  if (!isNaN(parsedStep)) step = parsedStep;
576
661
  }
577
- this.rotateImage(this.currentRotation + step);
662
+ this.rotateImage(this.currentRotation + step).catch(error => this._reportError('rotateImage failed', error));
578
663
  });
579
664
 
580
665
  // Crop bindings (optional: bound only if element IDs exist in elements)
581
666
  this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
582
667
  this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
583
668
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
669
+ this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
584
670
  }
585
671
 
586
672
  /**
@@ -592,7 +678,7 @@ function ensureFabric() {
592
678
  * @private
593
679
  */
594
680
  _bindIfExists(key, eventName, handler) {
595
- const element = document.getElementById(this.elements[key]);
681
+ const element = this._getElement(key);
596
682
  if (element) {
597
683
  element.addEventListener(eventName, handler);
598
684
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -605,14 +691,37 @@ function ensureFabric() {
605
691
  * Reads an image File as a data URL and loads it into the Fabric canvas.
606
692
  *
607
693
  * @param {File} file - Image file selected by the user.
694
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
608
695
  * @private
609
696
  */
610
697
  _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);
698
+ if (!this._isSupportedImageFile(file)) {
699
+ const error = new Error('Selected file is not a supported image');
700
+ this._reportError('Selected file is not a supported image', error);
701
+ return Promise.reject(error);
702
+ }
703
+
704
+ return new Promise((resolve, reject) => {
705
+ const reader = new FileReader();
706
+ reader.onload = (event) => {
707
+ this.loadImage(event.target.result)
708
+ .then(resolve)
709
+ .catch(reject);
710
+ };
711
+ reader.onerror = (event) => {
712
+ const error = new Error('Image file could not be read');
713
+ this._reportError('Image file could not be read', event);
714
+ reject(error);
715
+ };
716
+ reader.readAsDataURL(file);
717
+ });
718
+ }
719
+
720
+ _isSupportedImageFile(file) {
721
+ if (!file) return false;
722
+ if (typeof file.type === 'string' && file.type.startsWith('image/')) return true;
723
+ const fileName = String(file.name || '');
724
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
616
725
  }
617
726
 
618
727
  /**
@@ -645,120 +754,118 @@ function ensureFabric() {
645
754
  */
646
755
  async loadImage(imageBase64, options = {}) {
647
756
  if (!this._fabricLoaded) return;
648
- if (!this.canvas) return;
757
+ if (!this.canvas || this._disposed) return;
649
758
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
759
+ this._assertIdleForOperation('loadImage', options);
650
760
 
761
+ this._isLoading = true;
762
+ this._updateUI();
651
763
  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);
764
+ const transaction = this._captureLoadImageTransaction();
765
+
766
+ try {
767
+ const imageElement = await this._createImageElement(imageBase64);
768
+ if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
769
+
770
+ let loadSource = imageBase64;
771
+ if (this.options.downsampleOnLoad) {
772
+ const shouldResize =
773
+ imageElement.naturalWidth > this.options.downsampleMaxWidth ||
774
+ imageElement.naturalHeight > this.options.downsampleMaxHeight;
775
+ if (shouldResize) {
776
+ const ratio = Math.min(
777
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
778
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
779
+ );
780
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
781
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
782
+ loadSource = this._resampleImageToDataURL(
783
+ imageElement,
784
+ targetWidth,
785
+ targetHeight,
786
+ this._normalizeQuality(this.options.downsampleQuality),
787
+ imageBase64
788
+ );
789
+ }
670
790
  }
671
- }
672
791
 
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
- }
792
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
793
+ if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
794
+
795
+ this.canvas.discardActiveObject();
796
+ this._hideAllMaskLabels();
797
+ this.canvas.clear();
798
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
799
+
800
+ fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
801
+ this._setPlaceholderVisible(false);
802
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
803
+
804
+ const imageWidth = fabricImage.width;
805
+ const imageHeight = fabricImage.height;
806
+
807
+ const viewport = this._getContainerViewportSize();
808
+ const minWidth = viewport.width;
809
+ const minHeight = viewport.height;
810
+
811
+ if (this.options.fitImageToCanvas) {
812
+ const canvasWidth = Math.max(1, minWidth - 1);
813
+ const canvasHeight = Math.max(1, minHeight - 1);
814
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
815
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
816
+ fabricImage.set({ left: 0, top: 0 });
817
+ fabricImage.scale(fitScale);
818
+ this.baseImageScale = fabricImage.scaleX || 1;
819
+ } else if (this.options.coverImageToCanvas) {
820
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
821
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
822
+ fabricImage.set({ left: 0, top: 0 });
823
+ fabricImage.scale(layout.scale);
824
+ this.baseImageScale = fabricImage.scaleX || 1;
825
+ } else if (this.options.expandCanvasToImage) {
826
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
827
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
828
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
829
+ fabricImage.set({ left: 0, top: 0 });
830
+ fabricImage.scale(1);
831
+ this.baseImageScale = 1;
832
+ } else {
833
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
834
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
835
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
836
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
837
+ fabricImage.set({ left: 0, top: 0 });
838
+ fabricImage.scale(fitScale);
839
+ this.baseImageScale = fabricImage.scaleX || 1;
840
+ }
751
841
 
752
- if (typeof this.onImageLoaded === 'function') {
753
- this.onImageLoaded();
754
- }
842
+ this.originalImage = fabricImage;
843
+ this.canvas.add(fabricImage);
844
+ this.canvas.sendToBack(fabricImage);
755
845
 
756
- resolve();
757
- } catch (error) {
758
- reject(error);
759
- }
760
- }, { crossOrigin: 'anonymous' });
761
- });
846
+ this._clearMaskPlacementMemory();
847
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
848
+ this.currentScale = 1;
849
+ this.currentRotation = 0;
850
+
851
+ // this._setPlaceholderVisible(false);
852
+ this._updateInputs();
853
+ this._updateMaskList();
854
+ this.isImageLoadedToCanvas = true;
855
+ this._updateUI();
856
+ this.canvas.renderAll();
857
+ this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
858
+
859
+ if (typeof this.onImageLoaded === 'function') {
860
+ this.onImageLoaded();
861
+ }
862
+ } catch (error) {
863
+ await this._rollbackLoadImageTransaction(transaction);
864
+ throw error;
865
+ } finally {
866
+ this._isLoading = false;
867
+ if (!this._disposed && this.canvas) this._updateUI();
868
+ }
762
869
  }
763
870
 
764
871
  /**
@@ -810,24 +917,167 @@ function ensureFabric() {
810
917
  });
811
918
  }
812
919
 
920
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
921
+ return new Promise((resolve, reject) => {
922
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
923
+ let isSettled = false;
924
+ let timerId;
925
+ const settle = (callback) => {
926
+ if (isSettled) return;
927
+ isSettled = true;
928
+ clearTimeout(timerId);
929
+ callback();
930
+ };
931
+
932
+ timerId = setTimeout(() => {
933
+ settle(() => reject(new Error('Fabric image load timed out')));
934
+ }, safeTimeoutMs);
935
+
936
+ try {
937
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
938
+ settle(() => {
939
+ if (!fabricImage) {
940
+ reject(new Error('Image could not be loaded'));
941
+ return;
942
+ }
943
+ resolve(fabricImage);
944
+ });
945
+ }, { crossOrigin: 'anonymous' });
946
+ } catch (error) {
947
+ settle(() => reject(error));
948
+ }
949
+ });
950
+ }
951
+
952
+ _getSafeTimeoutMs(timeoutMs) {
953
+ const safeTimeoutMs = Number(timeoutMs);
954
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 30000;
955
+ }
956
+
957
+ _captureLoadImageTransaction() {
958
+ return {
959
+ canvasState: this._serializeCanvasState(),
960
+ originalImage: this.originalImage,
961
+ baseImageScale: this.baseImageScale,
962
+ currentScale: this.currentScale,
963
+ currentRotation: this.currentRotation,
964
+ maskCounter: this.maskCounter,
965
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
966
+ lastSnapshot: this._lastSnapshot,
967
+ lastMask: this._lastMask,
968
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
969
+ lastMaskInitialTop: this._lastMaskInitialTop,
970
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
971
+ containerOverflow: this.containerElement && this.containerElement.style ? {
972
+ overflow: this.containerElement.style.overflow || '',
973
+ overflowX: this.containerElement.style.overflowX || '',
974
+ overflowY: this.containerElement.style.overflowY || ''
975
+ } : null,
976
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
977
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
978
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
979
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
980
+ };
981
+ }
982
+
983
+ async _rollbackLoadImageTransaction(transaction) {
984
+ if (!transaction || !this.canvas || this._disposed) return;
985
+ let didRestoreCanvasState = false;
986
+ try {
987
+ if (transaction.canvasState) {
988
+ await this.loadFromState(transaction.canvasState);
989
+ didRestoreCanvasState = true;
990
+ }
991
+ } catch (error) {
992
+ this._lastMask = null;
993
+ this._reportError('loadImage rollback failed', error);
994
+ }
995
+
996
+ this.baseImageScale = transaction.baseImageScale;
997
+ this.currentScale = transaction.currentScale;
998
+ this.currentRotation = transaction.currentRotation;
999
+ this.maskCounter = transaction.maskCounter;
1000
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
1001
+ this._lastSnapshot = transaction.lastSnapshot;
1002
+ if (didRestoreCanvasState) {
1003
+ this._restoreLastMaskReference(transaction.lastMask);
1004
+ } else {
1005
+ this._lastMask = null;
1006
+ }
1007
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
1008
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
1009
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
1010
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
1011
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
1012
+ if (this.containerElement) {
1013
+ this.containerElement.scrollLeft = transaction.scrollLeft;
1014
+ this.containerElement.scrollTop = transaction.scrollTop;
1015
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
1016
+ }
1017
+ this._updateInputs();
1018
+ this._updateMaskList();
1019
+ this._updateUI();
1020
+ if (this.canvas) this.canvas.renderAll();
1021
+ }
1022
+
1023
+ _restoreLastMaskReference(previousLastMask) {
1024
+ if (!this.canvas) {
1025
+ this._lastMask = null;
1026
+ return;
1027
+ }
1028
+
1029
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1030
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
1031
+ this._lastMask = masks.find(mask => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
1032
+ if (!this._lastMask) {
1033
+ this._lastMaskInitialLeft = null;
1034
+ this._lastMaskInitialTop = null;
1035
+ this._lastMaskInitialWidth = null;
1036
+ }
1037
+ }
1038
+
813
1039
  /**
814
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
1040
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
815
1041
  *
816
1042
  * @param {HTMLImageElement} imageElement - The image element to resample.
817
1043
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
818
1044
  * @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.
1045
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
1046
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
1047
+ * @returns {string} A data URL representing the resampled image.
821
1048
  * @private
822
1049
  */
823
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
1050
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
824
1051
  const offscreenCanvas = document.createElement('canvas');
825
1052
  offscreenCanvas.width = targetWidth;
826
1053
  offscreenCanvas.height = targetHeight;
827
1054
  const context = offscreenCanvas.getContext('2d');
828
1055
  if (!context) throw new Error('2D canvas context is unavailable');
829
1056
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
830
- return offscreenCanvas.toDataURL('image/jpeg', quality);
1057
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
1058
+ }
1059
+
1060
+ _getDataUrlMimeType(dataUrl) {
1061
+ const match = String(dataUrl || '').match(/^data:([^;,]+)[;,]/i);
1062
+ return match ? match[1].toLowerCase() : '';
1063
+ }
1064
+
1065
+ _getDownsampleMimeType(sourceDataUrl) {
1066
+ if (this.options.downsampleMimeType) {
1067
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
1068
+ return `image/${requestedFormat}`;
1069
+ }
1070
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
1071
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === 'image/png' || sourceMimeType === 'image/webp')) {
1072
+ return sourceMimeType;
1073
+ }
1074
+ return 'image/jpeg';
1075
+ }
1076
+
1077
+ _captureCanvasStateOrThrow(context) {
1078
+ const snapshot = this._serializeCanvasState();
1079
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
1080
+ return snapshot;
831
1081
  }
832
1082
 
833
1083
  /**
@@ -849,7 +1099,6 @@ function ensureFabric() {
849
1099
  if (this.canvasElement) {
850
1100
  this.canvasElement.style.width = integerWidth + 'px';
851
1101
  this.canvasElement.style.height = integerHeight + 'px';
852
- this.canvasElement.style.maxWidth = 'none';
853
1102
  }
854
1103
  }
855
1104
 
@@ -868,8 +1117,14 @@ function ensureFabric() {
868
1117
  };
869
1118
  }
870
1119
 
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));
1120
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
1121
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
1122
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
1123
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
1124
+
1125
+ if (measuredWidth > 0 && measuredHeight > 0) {
1126
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
1127
+ }
873
1128
 
874
1129
  if (this._hasFixedContainerScrollbars()) {
875
1130
  return { width, height };
@@ -1106,7 +1361,11 @@ function ensureFabric() {
1106
1361
  maskStyleBackups.push(backup);
1107
1362
  mask.set(stylePatch);
1108
1363
  });
1109
- return callback();
1364
+ const result = callback();
1365
+ if (result && typeof result.then === 'function') {
1366
+ throw new Error('_withNormalizedMaskStyles callback must be synchronous');
1367
+ }
1368
+ return result;
1110
1369
  } finally {
1111
1370
  maskStyleBackups.forEach(backup => {
1112
1371
  try {
@@ -1178,9 +1437,15 @@ function ensureFabric() {
1178
1437
  * @returns {number} A finite quality value between 0 and 1.
1179
1438
  * @private
1180
1439
  */
1181
- _normalizeQuality(quality) {
1440
+ _normalizeQuality(quality, fallback = undefined) {
1441
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1442
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1443
+ const safeFallback = Number.isFinite(numericFallback)
1444
+ ? Math.max(0, Math.min(1, numericFallback))
1445
+ : 0.92;
1446
+ if (quality == null) return safeFallback;
1182
1447
  const numericQuality = Number(quality);
1183
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1448
+ if (!Number.isFinite(numericQuality)) return safeFallback;
1184
1449
  return Math.max(0, Math.min(1, numericQuality));
1185
1450
  }
1186
1451
 
@@ -1235,65 +1500,74 @@ function ensureFabric() {
1235
1500
  };
1236
1501
  }
1237
1502
 
1238
- /**
1239
- * Crops an image data URL to a source region using an offscreen canvas.
1240
- *
1241
- * @param {string} dataUrl - Source image data URL.
1242
- * @param {number} sourceX - Source region x coordinate.
1243
- * @param {number} sourceY - Source region y coordinate.
1244
- * @param {number} sourceWidth - Source region width.
1245
- * @param {number} sourceHeight - Source region height.
1246
- * @param {number} multiplier - Export multiplier already applied to the source data URL.
1247
- * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1248
- * @param {number} [quality=0.92] - Output image quality for lossy formats.
1249
- * @returns {Promise<string>} Resolves with the cropped image data URL.
1250
- * @private
1251
- */
1252
- async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1253
- return new Promise((resolve, reject) => {
1254
- const imageElement = new Image();
1255
- let isSettled = false;
1256
- const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1257
- const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
1258
- let timerId;
1259
- const settle = (callback) => {
1260
- if (isSettled) return;
1261
- isSettled = true;
1262
- clearTimeout(timerId);
1263
- imageElement.onload = null;
1264
- imageElement.onerror = null;
1265
- callback();
1266
- };
1267
- timerId = setTimeout(() => {
1268
- settle(() => reject(new Error('Image crop load timed out')));
1269
- try { imageElement.src = ''; } catch (error) { void error; }
1270
- }, safeTimeoutMs);
1271
- imageElement.onload = () => {
1272
- try {
1273
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1274
- const scaledSourceX = Math.round(sourceX * safeMultiplier);
1275
- const scaledSourceY = Math.round(sourceY * safeMultiplier);
1276
- const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1277
- const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1278
- const offscreenCanvas = document.createElement('canvas');
1279
- offscreenCanvas.width = scaledSourceWidth;
1280
- offscreenCanvas.height = scaledSourceHeight;
1281
- const context = offscreenCanvas.getContext('2d');
1282
- if (!context) throw new Error('2D canvas context is unavailable');
1283
-
1284
- context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1285
- settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1286
- } catch (error) {
1287
- settle(() => reject(error));
1288
- }
1289
- };
1290
- imageElement.onerror = (error) => settle(() => reject(error));
1291
- imageElement.src = dataUrl;
1292
- });
1503
+ _hasFractionalCanvasEdge(value) {
1504
+ const numericValue = Number(value);
1505
+ if (!Number.isFinite(numericValue)) return false;
1506
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1507
+ }
1508
+
1509
+ _getPartialExportEdges(bounds) {
1510
+ if (!bounds) return null;
1511
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1512
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1513
+ if (!isAxisAligned) return null;
1514
+
1515
+ return {
1516
+ left: this._hasFractionalCanvasEdge(bounds.left),
1517
+ top: this._hasFractionalCanvasEdge(bounds.top),
1518
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1519
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1520
+ };
1521
+ }
1522
+
1523
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1524
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1525
+
1526
+ const imageElement = await this._createImageElement(dataUrl);
1527
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1528
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1529
+ const offscreenCanvas = document.createElement('canvas');
1530
+ offscreenCanvas.width = width;
1531
+ offscreenCanvas.height = height;
1532
+ const context = offscreenCanvas.getContext('2d');
1533
+ if (!context) throw new Error('2D canvas context is unavailable');
1534
+ context.drawImage(imageElement, 0, 0, width, height);
1535
+
1536
+ const imageData = context.getImageData(0, 0, width, height);
1537
+ const pixels = imageData.data;
1538
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1539
+ const index = (y * width + x) * 4;
1540
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1541
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1542
+ pixels[index] = pixels[fallbackIndex];
1543
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1544
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1545
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1546
+ }
1547
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1548
+ pixels[index + 3] = 255;
1549
+ }
1550
+ };
1551
+
1552
+ if (edges.left && width > 1) {
1553
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1554
+ }
1555
+ if (edges.right && width > 1) {
1556
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1557
+ }
1558
+ if (edges.top && height > 1) {
1559
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1560
+ }
1561
+ if (edges.bottom && height > 1) {
1562
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1563
+ }
1564
+
1565
+ context.putImageData(imageData, 0, 0);
1566
+ return offscreenCanvas.toDataURL('image/png');
1293
1567
  }
1294
1568
 
1295
1569
  /**
1296
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1570
+ * Exports a source region directly through Fabric's region export options.
1297
1571
  *
1298
1572
  * @param {Object} region - Canvas source region and export options.
1299
1573
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1303,18 +1577,49 @@ function ensureFabric() {
1303
1577
  * @param {number} [region.multiplier=1] - Export multiplier.
1304
1578
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1305
1579
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1580
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1306
1581
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1307
1582
  * @private
1308
1583
  */
1309
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1584
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
1310
1585
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1311
- const fullDataUrl = this.canvas.toDataURL({
1312
- format,
1586
+ const safeFormat = this._normalizeImageFormat(format);
1587
+ const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1588
+ let regionDataUrl = this.canvas.toDataURL({
1589
+ format: exportFormat,
1313
1590
  quality,
1314
- multiplier: safeMultiplier
1591
+ multiplier: safeMultiplier,
1592
+ left: sourceX,
1593
+ top: sourceY,
1594
+ width: sourceWidth,
1595
+ height: sourceHeight
1315
1596
  });
1316
1597
 
1317
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1598
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1599
+ if (safeFormat !== 'jpeg') return regionDataUrl;
1600
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1601
+ }
1602
+
1603
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1604
+ const imageElement = await this._createImageElement(dataUrl);
1605
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1606
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1607
+ const offscreenCanvas = document.createElement('canvas');
1608
+ offscreenCanvas.width = width;
1609
+ offscreenCanvas.height = height;
1610
+ const context = offscreenCanvas.getContext('2d');
1611
+ if (!context) throw new Error('2D canvas context is unavailable');
1612
+ context.fillStyle = this._getJpegBackgroundColor();
1613
+ context.fillRect(0, 0, width, height);
1614
+ context.drawImage(imageElement, 0, 0, width, height);
1615
+ return offscreenCanvas.toDataURL('image/jpeg', this._normalizeQuality(quality));
1616
+ }
1617
+
1618
+ _getJpegBackgroundColor() {
1619
+ const backgroundColor = String(this.options.backgroundColor || '').trim();
1620
+ if (!backgroundColor || backgroundColor === 'transparent') return '#ffffff';
1621
+ if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return '#ffffff';
1622
+ return backgroundColor;
1318
1623
  }
1319
1624
 
1320
1625
  /**
@@ -1328,12 +1633,41 @@ function ensureFabric() {
1328
1633
  _getObjectTopLeftPoint(fabricObject) {
1329
1634
  if (!fabricObject) return { x: 0, y: 0 };
1330
1635
  fabricObject.setCoords();
1331
- const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
1332
- if (coords && coords.length) return coords[0];
1333
1636
  const boundingRect = fabricObject.getBoundingRect(true, true);
1334
1637
  return { x: boundingRect.left, y: boundingRect.top };
1335
1638
  }
1336
1639
 
1640
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1641
+ if (!fabricObject) return { x: 0, y: 0 };
1642
+ fabricObject.setCoords();
1643
+ const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
1644
+ if (coords && coords.length) return coords[0];
1645
+ return this._getObjectTopLeftPoint(fabricObject);
1646
+ }
1647
+
1648
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1649
+ if (!fabricObject) return { x: 0, y: 0 };
1650
+ if (typeof fabricObject.getPointByOrigin === 'function') {
1651
+ return fabricObject.getPointByOrigin(originX, originY);
1652
+ }
1653
+ return this._getObjectTopLeftPoint(fabricObject);
1654
+ }
1655
+
1656
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1657
+ if (!fabricObject) return;
1658
+ if (typeof fabricObject.getCenterPoint === 'function' && typeof fabricObject.setPositionByOrigin === 'function') {
1659
+ const center = fabricObject.getCenterPoint();
1660
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1661
+ fabricObject.setPositionByOrigin(nextCenter, 'center', 'center');
1662
+ } else {
1663
+ fabricObject.set({
1664
+ left: (fabricObject.left || 0) + deltaX,
1665
+ top: (fabricObject.top || 0) + deltaY
1666
+ });
1667
+ }
1668
+ fabricObject.setCoords();
1669
+ }
1670
+
1337
1671
  /**
1338
1672
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1339
1673
  *
@@ -1402,8 +1736,10 @@ function ensureFabric() {
1402
1736
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1403
1737
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1404
1738
  try {
1405
- let requiredWidth = this.canvas.getWidth();
1406
- let requiredHeight = this.canvas.getHeight();
1739
+ const currentWidth = this.canvas.getWidth();
1740
+ const currentHeight = this.canvas.getHeight();
1741
+ let requiredWidth = currentWidth;
1742
+ let requiredHeight = currentHeight;
1407
1743
  fabricObjects.forEach(fabricObject => {
1408
1744
  if (!fabricObject) return;
1409
1745
  if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
@@ -1411,11 +1747,23 @@ function ensureFabric() {
1411
1747
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1412
1748
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1413
1749
  });
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()) {
1750
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1751
+
1752
+ let minWidth = 0;
1753
+ let minHeight = 0;
1754
+ if (shouldUseScrollSafeViewport) {
1755
+ const viewport = this._getContainerViewportSize();
1756
+ const safetyMargin = this._getScrollSafetyMargin();
1757
+
1758
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1759
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1760
+ } else if (this.containerElement) {
1761
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1762
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1763
+ }
1764
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1765
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1766
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1419
1767
  this._setCanvasSizeInt(newWidth, newHeight);
1420
1768
  }
1421
1769
  } catch (error) {
@@ -1443,7 +1791,131 @@ function ensureFabric() {
1443
1791
  * @public
1444
1792
  */
1445
1793
  scaleImage(factor, options = {}) {
1446
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1794
+ try {
1795
+ this._assertCanQueueAnimation('scaleImage', options);
1796
+ } catch (error) {
1797
+ return Promise.reject(error);
1798
+ }
1799
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
1800
+ .finally(() => {
1801
+ if (!this._disposed && this.canvas) this._updateUI();
1802
+ });
1803
+ }
1804
+
1805
+ _getInternalOperationToken(options) {
1806
+ return options && options[INTERNAL_OPERATION_TOKEN];
1807
+ }
1808
+
1809
+ _isOwnInternalOperation(options) {
1810
+ const token = this._getInternalOperationToken(options);
1811
+ return !!token && token === this._activeOperationToken;
1812
+ }
1813
+
1814
+ _beginBusyOperation(operationName) {
1815
+ const token = Symbol(operationName);
1816
+ this._activeOperationName = operationName;
1817
+ this._activeOperationToken = token;
1818
+ this._updateUI();
1819
+ return token;
1820
+ }
1821
+
1822
+ _endBusyOperation(token) {
1823
+ if (token && token === this._activeOperationToken) {
1824
+ this._activeOperationName = null;
1825
+ this._activeOperationToken = null;
1826
+ this._updateUI();
1827
+ }
1828
+ }
1829
+
1830
+ _withInternalOperationOptions(token, options = {}) {
1831
+ return {
1832
+ ...options,
1833
+ [INTERNAL_OPERATION_TOKEN]: token
1834
+ };
1835
+ }
1836
+
1837
+ _assertEditorAvailable(operationName) {
1838
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1839
+ }
1840
+
1841
+ _assertIdleForOperation(operationName, options = {}) {
1842
+ this._assertEditorAvailable(operationName);
1843
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1844
+ if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1845
+ throw new Error(`${operationName} cannot run while an animation is running`);
1846
+ }
1847
+ if (this._isLoading && !isOwnInternalOperation) {
1848
+ throw new Error(`${operationName} cannot run while an image is loading`);
1849
+ }
1850
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1851
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1852
+ }
1853
+ }
1854
+
1855
+ _assertCanQueueAnimation(operationName, options = {}) {
1856
+ this._assertEditorAvailable(operationName);
1857
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1858
+ throw new Error(`${operationName} cannot run while an image is loading`);
1859
+ }
1860
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1861
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1862
+ }
1863
+ }
1864
+
1865
+ _canMutateNow(operationName, options = {}) {
1866
+ try {
1867
+ this._assertIdleForOperation(operationName, options);
1868
+ return true;
1869
+ } catch (error) {
1870
+ this._reportError(`${operationName} blocked`, error);
1871
+ return false;
1872
+ }
1873
+ }
1874
+
1875
+ _rejectActiveAnimations(reason) {
1876
+ const error = reason instanceof Error ? reason : new Error(String(reason || 'Animation cancelled'));
1877
+ this._activeAnimationRejectors.forEach(reject => {
1878
+ try { reject(error); } catch (rejectError) { void rejectError; }
1879
+ });
1880
+ this._activeAnimationRejectors.clear();
1881
+ }
1882
+
1883
+ _animateFabricProperty(fabricObject, property, value) {
1884
+ return new Promise((resolve, reject) => {
1885
+ if (this._disposed || !this.canvas || !fabricObject) {
1886
+ reject(new Error('Animation cannot start after editor disposal'));
1887
+ return;
1888
+ }
1889
+
1890
+ let isSettled = false;
1891
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1892
+ const timeoutMs = Math.max(1000, duration + 1000);
1893
+ let timerId;
1894
+ const settle = (callback) => {
1895
+ if (isSettled) return;
1896
+ isSettled = true;
1897
+ clearTimeout(timerId);
1898
+ this._activeAnimationRejectors.delete(reject);
1899
+ callback();
1900
+ };
1901
+
1902
+ this._activeAnimationRejectors.add(reject);
1903
+ timerId = setTimeout(() => {
1904
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1905
+ }, timeoutMs);
1906
+
1907
+ try {
1908
+ fabricObject.animate(property, value, {
1909
+ duration,
1910
+ onChange: () => {
1911
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1912
+ },
1913
+ onComplete: () => settle(resolve)
1914
+ });
1915
+ } catch (error) {
1916
+ settle(() => reject(error));
1917
+ }
1918
+ });
1447
1919
  }
1448
1920
 
1449
1921
  /**
@@ -1453,37 +1925,29 @@ function ensureFabric() {
1453
1925
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1454
1926
  * @private
1455
1927
  */
1456
- _scaleImageImpl(factor, options = {}) {
1457
- if (!this.originalImage) return Promise.resolve();
1458
- if (this.isAnimating) return Promise.resolve();
1928
+ async _scaleImageImpl(factor, options = {}) {
1929
+ if (!this.originalImage || this._disposed) return;
1930
+ if (this.isAnimating) return;
1459
1931
  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();
1932
+ let didStartAnimation = false;
1933
+ try {
1934
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1935
+ this.currentScale = factor;
1936
+ this.isAnimating = true;
1937
+ didStartAnimation = true;
1938
+ this._updateUI();
1464
1939
 
1465
- const targetScale = this.baseImageScale * factor;
1940
+ const targetScale = this.baseImageScale * factor;
1466
1941
 
1467
- // Scale around current top-left (recompute)
1468
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1469
- this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1942
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1943
+ this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
1470
1944
 
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
- });
1945
+ await Promise.all([
1946
+ this._animateFabricProperty(this.originalImage, 'scaleX', targetScale),
1947
+ this._animateFabricProperty(this.originalImage, 'scaleY', targetScale)
1948
+ ]);
1949
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during scale animation');
1485
1950
 
1486
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1487
1951
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1488
1952
  this.originalImage.setCoords();
1489
1953
 
@@ -1493,17 +1957,17 @@ function ensureFabric() {
1493
1957
 
1494
1958
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1495
1959
 
1496
- // Sync mask labels
1497
1960
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1498
1961
 
1499
- this.isAnimating = false;
1500
1962
  this._updateInputs();
1501
- this._updateUI();
1502
1963
  if (saveHistory) this.saveState();
1503
- }).catch(() => {
1504
- this.isAnimating = false;
1505
- this._updateUI();
1506
- });
1964
+ } finally {
1965
+ if (didStartAnimation) {
1966
+ this.isAnimating = false;
1967
+ this._updateInputs();
1968
+ this._updateUI();
1969
+ }
1970
+ }
1507
1971
  }
1508
1972
 
1509
1973
  /**
@@ -1514,7 +1978,15 @@ function ensureFabric() {
1514
1978
  * @public
1515
1979
  */
1516
1980
  rotateImage(degrees, options = {}) {
1517
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1981
+ try {
1982
+ this._assertCanQueueAnimation('rotateImage', options);
1983
+ } catch (error) {
1984
+ return Promise.reject(error);
1985
+ }
1986
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
1987
+ .finally(() => {
1988
+ if (!this._disposed && this.canvas) this._updateUI();
1989
+ });
1518
1990
  }
1519
1991
 
1520
1992
  /**
@@ -1524,27 +1996,29 @@ function ensureFabric() {
1524
1996
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1525
1997
  * @private
1526
1998
  */
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();
1999
+ async _rotateImageImpl(degrees, options = {}) {
2000
+ if (!this.originalImage || this._disposed) return;
2001
+ if (this.isAnimating) return;
2002
+ if (isNaN(degrees)) return;
1531
2003
  const saveHistory = options.saveHistory !== false;
1532
- this.currentRotation = degrees;
1533
- this.isAnimating = true;
1534
- this._updateUI();
2004
+ const image = this.originalImage;
2005
+ const previousOriginX = image.originX || 'left';
2006
+ const previousOriginY = image.originY || 'top';
2007
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
2008
+ let didStartAnimation = false;
2009
+ let didCompleteRotation = false;
2010
+ try {
2011
+ this.currentRotation = degrees;
2012
+ this.isAnimating = true;
2013
+ didStartAnimation = true;
2014
+ this._updateUI();
1535
2015
 
1536
- const center = this.originalImage.getCenterPoint();
1537
- this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
2016
+ const center = image.getCenterPoint();
2017
+ this._setObjectOriginKeepingPosition(image, 'center', 'center', center);
1538
2018
 
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
- });
2019
+ await this._animateFabricProperty(image, 'angle', degrees);
2020
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during rotation animation');
1546
2021
 
1547
- return rotationAnimation.then(() => {
1548
2022
  this.originalImage.set('angle', degrees);
1549
2023
  this.originalImage.setCoords();
1550
2024
 
@@ -1554,20 +2028,24 @@ function ensureFabric() {
1554
2028
 
1555
2029
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1556
2030
 
1557
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
2031
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1558
2032
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
1559
2033
 
1560
- // Sync mask labels
1561
2034
  this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
1562
2035
 
1563
- this.isAnimating = false;
1564
2036
  this._updateInputs();
1565
- this._updateUI();
1566
2037
  if (saveHistory) this.saveState();
1567
- }).catch(() => {
1568
- this.isAnimating = false;
1569
- this._updateUI();
1570
- });
2038
+ didCompleteRotation = true;
2039
+ } finally {
2040
+ if (!didCompleteRotation && !this._disposed && image) {
2041
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
2042
+ }
2043
+ if (didStartAnimation) {
2044
+ this.isAnimating = false;
2045
+ this._updateInputs();
2046
+ this._updateUI();
2047
+ }
2048
+ }
1571
2049
  }
1572
2050
 
1573
2051
  /**
@@ -1578,15 +2056,23 @@ function ensureFabric() {
1578
2056
  */
1579
2057
  resetImageTransform() {
1580
2058
  if (!this.originalImage) return Promise.resolve();
2059
+ try {
2060
+ this._assertCanQueueAnimation('resetImageTransform');
2061
+ } catch (error) {
2062
+ return Promise.reject(error);
2063
+ }
1581
2064
 
1582
2065
  return this.animationQueue.add(async () => {
1583
- const before = this._lastSnapshot || this._serializeCanvasState();
2066
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
1584
2067
  await this._scaleImageImpl(1, { saveHistory: false });
1585
2068
  await this._rotateImageImpl(0, { saveHistory: false });
1586
- const after = this._serializeCanvasState();
2069
+ const after = this._captureCanvasStateOrThrow('resetImageTransform');
1587
2070
  this._pushStateTransition(before, after);
2071
+ }).finally(() => {
2072
+ if (!this._disposed && this.canvas) this._updateUI();
1588
2073
  }).catch(error => {
1589
2074
  this._reportError('resetImageTransform() failed', error);
2075
+ throw error;
1590
2076
  });
1591
2077
  }
1592
2078
 
@@ -1608,17 +2094,35 @@ function ensureFabric() {
1608
2094
  * @public
1609
2095
  */
1610
2096
  loadFromState(serializedState) {
1611
- if (!serializedState || !this.canvas) return Promise.resolve();
2097
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
2098
+ if (this._cropMode || this._cropRect) {
2099
+ this._removeCropRect();
2100
+ this._restoreCropObjectState();
2101
+ this._cropMode = false;
2102
+ if (this._prevSelectionSetting !== undefined && this.canvas) {
2103
+ this.canvas.selection = !!this._prevSelectionSetting;
2104
+ }
2105
+ this._prevSelectionSetting = undefined;
2106
+ }
1612
2107
 
1613
- return new Promise((resolve) => {
2108
+ return new Promise((resolve, reject) => {
1614
2109
  try {
1615
2110
  const state = (typeof serializedState === 'string')
1616
2111
  ? JSON.parse(serializedState)
1617
2112
  : serializedState;
1618
2113
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1619
2114
 
1620
- this.canvas.loadFromJSON(state, () => {
2115
+ this.canvas.loadFromJSON(state, async () => {
1621
2116
  try {
2117
+ if (this._disposed || !this.canvas) {
2118
+ reject(new Error('Editor was disposed while loading state'));
2119
+ return;
2120
+ }
2121
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
2122
+ if (this._disposed || !this.canvas) {
2123
+ reject(new Error('Editor was disposed while loading state'));
2124
+ return;
2125
+ }
1622
2126
  this._hideAllMaskLabels();
1623
2127
  const canvasObjects = this.canvas.getObjects();
1624
2128
  this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
@@ -1677,16 +2181,56 @@ function ensureFabric() {
1677
2181
  this._updatePlaceholderStatus();
1678
2182
  this._lastSnapshot = this._serializeCanvasState();
1679
2183
  this._updateUI();
2184
+ resolve();
1680
2185
  } catch (callbackError) {
1681
2186
  this._reportError('loadFromState() failed', callbackError);
1682
- } finally {
1683
- resolve();
2187
+ reject(callbackError);
1684
2188
  }
1685
2189
  });
1686
2190
 
1687
2191
  } catch (error) {
1688
2192
  this._reportError('loadFromState() failed', error);
1689
- resolve();
2193
+ reject(error);
2194
+ }
2195
+ });
2196
+ }
2197
+
2198
+ async _waitForFabricImagesReady(canvasObjects) {
2199
+ const imageObjects = (canvasObjects || []).filter(object => object && object.type === 'image');
2200
+ await Promise.all(imageObjects.map(object => this._waitForImageElementReady(
2201
+ typeof object.getElement === 'function' ? object.getElement() : object._element
2202
+ )));
2203
+ }
2204
+
2205
+ _waitForImageElementReady(imageElement) {
2206
+ if (!imageElement) return Promise.resolve();
2207
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
2208
+ return new Promise((resolve, reject) => {
2209
+ let isSettled = false;
2210
+ const timerId = setTimeout(() => {
2211
+ settle(() => reject(new Error('Image load timed out while restoring state')));
2212
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2213
+ const settle = (callback) => {
2214
+ if (isSettled) return;
2215
+ isSettled = true;
2216
+ clearTimeout(timerId);
2217
+ if (typeof imageElement.removeEventListener === 'function') {
2218
+ imageElement.removeEventListener('load', handleLoad);
2219
+ imageElement.removeEventListener('error', handleError);
2220
+ } else {
2221
+ imageElement.onload = null;
2222
+ imageElement.onerror = null;
2223
+ }
2224
+ callback();
2225
+ };
2226
+ const handleLoad = () => settle(resolve);
2227
+ const handleError = (error) => settle(() => reject(error));
2228
+ if (typeof imageElement.addEventListener === 'function') {
2229
+ imageElement.addEventListener('load', handleLoad, { once: true });
2230
+ imageElement.addEventListener('error', handleError, { once: true });
2231
+ } else {
2232
+ imageElement.onload = handleLoad;
2233
+ imageElement.onerror = handleError;
1690
2234
  }
1691
2235
  });
1692
2236
  }
@@ -1702,10 +2246,9 @@ function ensureFabric() {
1702
2246
  */
1703
2247
  saveState() {
1704
2248
  if (!this.canvas) return;
1705
- const activeObject = this.canvas.getActiveObject();
1706
2249
 
1707
2250
  try {
1708
- const after = this._serializeCanvasState();
2251
+ const after = this._captureCanvasStateOrThrow('saveState');
1709
2252
  const before = this._lastSnapshot || after;
1710
2253
  if (after === before) return;
1711
2254
  let executedOnce = false;
@@ -1726,9 +2269,6 @@ function ensureFabric() {
1726
2269
  } catch (error) {
1727
2270
  this._reportWarning('saveState: failed to save canvas snapshot', error);
1728
2271
  } finally {
1729
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1730
- this._handleSelectionChanged([activeObject]);
1731
- }
1732
2272
  this._updateUI();
1733
2273
  }
1734
2274
  }
@@ -1745,7 +2285,10 @@ function ensureFabric() {
1745
2285
  * @private
1746
2286
  */
1747
2287
  _pushStateTransition(before, after) {
1748
- if (!before || !after) return;
2288
+ if (!before || !after) {
2289
+ this._reportWarning('History transition skipped because a canvas snapshot is unavailable');
2290
+ return;
2291
+ }
1749
2292
  if (before === after) return;
1750
2293
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1751
2294
 
@@ -1767,7 +2310,10 @@ function ensureFabric() {
1767
2310
  undo() {
1768
2311
  return this.historyManager.undo()
1769
2312
  .then(() => { this._updateUI(); })
1770
- .catch(error => { this._reportError('undo failed', error); });
2313
+ .catch(error => {
2314
+ this._reportError('undo failed', error);
2315
+ throw error;
2316
+ });
1771
2317
  }
1772
2318
 
1773
2319
  /**
@@ -1779,7 +2325,10 @@ function ensureFabric() {
1779
2325
  redo() {
1780
2326
  return this.historyManager.redo()
1781
2327
  .then(() => { this._updateUI(); })
1782
- .catch(error => { this._reportError('redo failed', error); });
2328
+ .catch(error => {
2329
+ this._reportError('redo failed', error);
2330
+ throw error;
2331
+ });
1783
2332
  }
1784
2333
 
1785
2334
  _rebindMaskEvents(mask) {
@@ -1801,23 +2350,17 @@ function ensureFabric() {
1801
2350
  }
1802
2351
  if (Object.keys(metadata).length) mask.set(metadata);
1803
2352
 
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
2353
  const mouseover = () => {
1816
- mask.set(hoverStyle);
2354
+ const opacity = Number(mask.originalAlpha);
2355
+ mask.set({
2356
+ stroke: '#ff5500',
2357
+ strokeWidth: 2,
2358
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
2359
+ });
1817
2360
  if (mask.canvas) mask.canvas.requestRenderAll();
1818
2361
  };
1819
2362
  const mouseout = () => {
1820
- mask.set(normalStyle);
2363
+ mask.set(this._getMaskNormalStyle(mask));
1821
2364
  if (mask.canvas) mask.canvas.requestRenderAll();
1822
2365
  };
1823
2366
 
@@ -1856,6 +2399,7 @@ function ensureFabric() {
1856
2399
  */
1857
2400
  createMask(config = {}) {
1858
2401
  if (!this.canvas) return null;
2402
+ if (!this._canMutateNow('createMask')) return null;
1859
2403
  const shapeType = config.shape || 'rect';
1860
2404
  // Normalize mask defaults before applying caller-provided overrides.
1861
2405
  const maskConfig = {
@@ -1898,15 +2442,12 @@ function ensureFabric() {
1898
2442
 
1899
2443
  if (maskConfig.left === undefined && this._lastMask) {
1900
2444
  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;
2445
+ if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
2446
+ const previousBounds = typeof previousMask.getBoundingRect === 'function'
2447
+ ? previousMask.getBoundingRect(true, true)
2448
+ : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2449
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2450
+ top = Math.round(previousBounds.top ?? firstOffset);
1910
2451
  } else {
1911
2452
  left = resolveValue(maskConfig.left, firstOffset, 'width');
1912
2453
  top = resolveValue(maskConfig.top, firstOffset, 'height');
@@ -2044,6 +2585,8 @@ function ensureFabric() {
2044
2585
  * The associated label is also removed. UI and mask list are updated.
2045
2586
  */
2046
2587
  removeSelectedMask() {
2588
+ if (!this.canvas) return;
2589
+ if (!this._canMutateNow('removeSelectedMask')) return;
2047
2590
  const activeObject = this.canvas.getActiveObject();
2048
2591
  const selectedMasks = this._getModifiedMasks(activeObject);
2049
2592
  if (!selectedMasks.length) return;
@@ -2072,6 +2615,8 @@ function ensureFabric() {
2072
2615
  * UI and internal mask placement memory are reset.
2073
2616
  */
2074
2617
  removeAllMasks(options = {}) {
2618
+ if (!this.canvas) return;
2619
+ if (!this._canMutateNow('removeAllMasks', options)) return;
2075
2620
  const saveHistory = options.saveHistory !== false;
2076
2621
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2077
2622
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -2137,6 +2682,10 @@ function ensureFabric() {
2137
2682
  let textObject = null;
2138
2683
  if (this.options.label && typeof this.options.label.create === 'function') {
2139
2684
  textObject = this.options.label.create(mask, fabric);
2685
+ if (!textObject || typeof textObject.set !== 'function') {
2686
+ this._reportWarning('label.create() returned an invalid Fabric object; using the default label');
2687
+ textObject = null;
2688
+ }
2140
2689
  }
2141
2690
  if (!textObject) {
2142
2691
  let labelText = mask.maskName;
@@ -2203,10 +2752,11 @@ function ensureFabric() {
2203
2752
  if (!this.options.maskLabelOnSelect) return;
2204
2753
  if (!mask.__label) return;
2205
2754
 
2206
- const coords = mask.getCoords ? mask.getCoords() : null;
2207
- if (!coords || coords.length < 4) return;
2755
+ if (typeof mask.setCoords === 'function') mask.setCoords();
2756
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2757
+ if (!bounds) return;
2208
2758
 
2209
- const tl = coords[0];
2759
+ const tl = { x: bounds.left, y: bounds.top };
2210
2760
  const center = mask.getCenterPoint();
2211
2761
 
2212
2762
  const vx = center.x - tl.x;
@@ -2289,7 +2839,7 @@ function ensureFabric() {
2289
2839
  * @private
2290
2840
  */
2291
2841
  _updateMaskList() {
2292
- const maskListElement = document.getElementById(this.elements.maskList);
2842
+ const maskListElement = this._getElement('maskList');
2293
2843
  if (!maskListElement) return;
2294
2844
  maskListElement.innerHTML = '';
2295
2845
  const masks = this.canvas.getObjects().filter(object => object.maskId);
@@ -2297,11 +2847,22 @@ function ensureFabric() {
2297
2847
  const listItemElement = document.createElement('li');
2298
2848
  listItemElement.className = 'list-group-item mask-item';
2299
2849
  listItemElement.textContent = mask.maskName;
2300
- listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
2850
+ listItemElement.dataset.maskId = String(mask.maskId);
2301
2851
  maskListElement.appendChild(listItemElement);
2302
2852
  });
2303
2853
  }
2304
2854
 
2855
+ _handleMaskListClick(event) {
2856
+ if (!this.canvas) return;
2857
+ const itemElement = event.target && event.target.closest ? event.target.closest('.mask-item') : null;
2858
+ if (!itemElement || !itemElement.dataset) return;
2859
+ const maskId = Number(itemElement.dataset.maskId);
2860
+ const mask = this.canvas.getObjects().find(object => Number(object.maskId) === maskId);
2861
+ if (!mask) return;
2862
+ this.canvas.setActiveObject(mask);
2863
+ this._handleSelectionChanged([mask]);
2864
+ }
2865
+
2305
2866
  /**
2306
2867
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
2307
2868
  *
@@ -2309,12 +2870,13 @@ function ensureFabric() {
2309
2870
  * @private
2310
2871
  */
2311
2872
  _updateMaskListSelection(selectedMask) {
2312
- const maskListElement = document.getElementById(this.elements.maskList);
2873
+ const maskListElement = this._getElement('maskList');
2313
2874
  if (!maskListElement) return;
2314
2875
  const maskItems = maskListElement.querySelectorAll('.mask-item');
2315
2876
  maskItems.forEach(item => {
2316
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2877
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
2317
2878
  item.classList.toggle('active', isSelected);
2879
+ item.classList.toggle('selected', isSelected);
2318
2880
  });
2319
2881
  }
2320
2882
 
@@ -2330,21 +2892,38 @@ function ensureFabric() {
2330
2892
  */
2331
2893
  async mergeMasks() {
2332
2894
  if (!this.originalImage) return;
2895
+ this._assertIdleForOperation('mergeMasks');
2333
2896
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2334
2897
  if (!masks.length) return;
2898
+ const beforeJson = this._serializeCanvasState();
2899
+ const operationToken = this._beginBusyOperation('mergeMasks');
2335
2900
 
2336
2901
  this.canvas.discardActiveObject();
2337
2902
  this.canvas.renderAll();
2338
2903
 
2339
2904
  try {
2340
- const beforeJson = this._serializeCanvasState();
2341
- const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2342
- this.removeAllMasks({ saveHistory: false });
2343
- await this.loadImage(merged, { preserveScroll: true });
2905
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
2906
+ exportImageArea: true,
2907
+ multiplier: this.options.exportMultiplier,
2908
+ fileType: 'png'
2909
+ }));
2910
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2911
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2912
+ preserveScroll: true,
2913
+ resetMaskCounter: false
2914
+ }));
2344
2915
  const afterJson = this._serializeCanvasState();
2345
2916
  this._pushStateTransition(beforeJson, afterJson);
2346
2917
  } catch (error) {
2347
2918
  this._reportError('merge error', error);
2919
+ try {
2920
+ await this.loadFromState(beforeJson);
2921
+ } catch (restoreError) {
2922
+ this._reportError('mergeMasks rollback failed', restoreError);
2923
+ }
2924
+ throw error;
2925
+ } finally {
2926
+ this._endBusyOperation(operationToken);
2348
2927
  }
2349
2928
  }
2350
2929
 
@@ -2368,6 +2947,7 @@ function ensureFabric() {
2368
2947
  */
2369
2948
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2370
2949
  if (!this.originalImage) return;
2950
+ if (!this._canMutateNow('downloadImage')) return;
2371
2951
  const exportImageArea = this.options.exportImageAreaByDefault;
2372
2952
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
2373
2953
  .then(imageBase64 => {
@@ -2399,6 +2979,7 @@ function ensureFabric() {
2399
2979
  */
2400
2980
  async exportImageBase64(options = {}) {
2401
2981
  if (!this.originalImage) throw new Error('No image loaded');
2982
+ this._assertIdleForOperation('exportImageBase64', options);
2402
2983
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2403
2984
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2404
2985
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2415,12 +2996,13 @@ function ensureFabric() {
2415
2996
 
2416
2997
  this.originalImage.setCoords();
2417
2998
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2418
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2999
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2419
3000
  return await this._exportCanvasRegionToDataURL({
2420
3001
  ...exportRegion,
2421
3002
  multiplier,
2422
3003
  quality,
2423
- format
3004
+ format,
3005
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2424
3006
  });
2425
3007
  } finally {
2426
3008
  maskVisibilityBackups.forEach(backup => {
@@ -2459,14 +3041,15 @@ function ensureFabric() {
2459
3041
  // Compute an integer canvas region for the base image.
2460
3042
  this.originalImage.setCoords();
2461
3043
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2462
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
3044
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2463
3045
 
2464
3046
  // Crop precisely in offscreen canvas
2465
3047
  finalBase64 = await this._exportCanvasRegionToDataURL({
2466
3048
  ...exportRegion,
2467
3049
  multiplier,
2468
3050
  quality,
2469
- format
3051
+ format,
3052
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2470
3053
  });
2471
3054
  } finally {
2472
3055
  maskStyleBackups.forEach(backup => {
@@ -2520,6 +3103,7 @@ function ensureFabric() {
2520
3103
  */
2521
3104
  async exportImageFile(options = {}) {
2522
3105
  if (!this.originalImage) throw new Error('No image loaded');
3106
+ this._assertIdleForOperation('exportImageFile');
2523
3107
  const {
2524
3108
  mergeMask = true,
2525
3109
  fileType = 'jpeg',
@@ -2529,6 +3113,7 @@ function ensureFabric() {
2529
3113
  } = options;
2530
3114
 
2531
3115
  const safeFileType = this._normalizeImageFormat(fileType);
3116
+ const normalizedQuality = this._normalizeQuality(quality);
2532
3117
 
2533
3118
  // Generate the data URL in the requested export mode.
2534
3119
  let imageBase64;
@@ -2536,14 +3121,14 @@ function ensureFabric() {
2536
3121
  imageBase64 = await this.exportImageBase64({
2537
3122
  exportImageArea: true,
2538
3123
  multiplier,
2539
- quality,
3124
+ quality: normalizedQuality,
2540
3125
  fileType: safeFileType
2541
3126
  });
2542
3127
  } else {
2543
3128
  imageBase64 = await this.exportImageBase64({
2544
3129
  exportImageArea: false,
2545
3130
  multiplier,
2546
- quality,
3131
+ quality: normalizedQuality,
2547
3132
  fileType: safeFileType
2548
3133
  });
2549
3134
  }
@@ -2561,8 +3146,9 @@ function ensureFabric() {
2561
3146
  offscreenCanvas.width = imageElement.width;
2562
3147
  offscreenCanvas.height = imageElement.height;
2563
3148
  const context = offscreenCanvas.getContext('2d');
3149
+ if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
2564
3150
  context.drawImage(imageElement, 0, 0);
2565
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
3151
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2566
3152
  resolve(convertedDataUrl);
2567
3153
  } catch (error) { reject(error); }
2568
3154
  };
@@ -2633,13 +3219,15 @@ function ensureFabric() {
2633
3219
  if (this._cropHandlers && this._cropHandlers.length) {
2634
3220
  this._cropHandlers.forEach(targetHandlers => {
2635
3221
  targetHandlers.handlers.forEach(handlerRecord => {
2636
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3222
+ if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
3223
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
3224
+ }
2637
3225
  });
2638
3226
  });
2639
3227
  }
2640
3228
  } catch (error) { void error; }
2641
3229
 
2642
- try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
3230
+ try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
2643
3231
  this._cropRect = null;
2644
3232
  this._cropHandlers = [];
2645
3233
  }
@@ -2655,7 +3243,9 @@ function ensureFabric() {
2655
3243
  */
2656
3244
  enterCropMode() {
2657
3245
  if (!this.canvas || !this.originalImage || this._cropMode) return;
3246
+ if (!this._canMutateNow('enterCropMode')) return;
2658
3247
  if (!this.isImageLoaded()) return;
3248
+ this._removeCropRect();
2659
3249
  this._cropMode = true;
2660
3250
 
2661
3251
  // Disable group selection so only the crop rectangle can be manipulated.
@@ -2789,6 +3379,7 @@ function ensureFabric() {
2789
3379
  */
2790
3380
  async applyCrop() {
2791
3381
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
3382
+ this._assertIdleForOperation('applyCrop');
2792
3383
 
2793
3384
  // Fabric does not update control coordinates automatically after programmatic transforms.
2794
3385
  this._cropRect.setCoords();
@@ -2824,12 +3415,8 @@ function ensureFabric() {
2824
3415
  this._removeLabelForMask(mask);
2825
3416
  this.canvas.remove(mask);
2826
3417
  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();
3418
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3419
+ mask.set({ visible: true });
2833
3420
  preservedMasks.push(mask);
2834
3421
  }
2835
3422
  } catch (error) {
@@ -2867,7 +3454,7 @@ function ensureFabric() {
2867
3454
 
2868
3455
  // Load the cropped image as the new base image.
2869
3456
  try {
2870
- await this.loadImage(croppedBase64);
3457
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2871
3458
  if (preservedMasks.length) {
2872
3459
  preservedMasks.forEach(mask => {
2873
3460
  this._rebindMaskEvents(mask);
@@ -2887,7 +3474,7 @@ function ensureFabric() {
2887
3474
  // Create an after snapshot and push one history command for the crop operation.
2888
3475
  let afterJson;
2889
3476
  try {
2890
- afterJson = this._serializeCanvasState();
3477
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2891
3478
  } catch (error) {
2892
3479
  this._reportWarning('applyCrop: failed to serialize after state', error);
2893
3480
  afterJson = null;
@@ -2913,7 +3500,7 @@ function ensureFabric() {
2913
3500
  * @private
2914
3501
  */
2915
3502
  _updateInputs() {
2916
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
3503
+ const scaleInputElement = this._getElement('scaleRate');
2917
3504
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2918
3505
  }
2919
3506
 
@@ -2923,6 +3510,7 @@ function ensureFabric() {
2923
3510
  * @private
2924
3511
  */
2925
3512
  _updateUI() {
3513
+ if (!this.canvas) return;
2926
3514
  const hasImage = !!this.originalImage;
2927
3515
  const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
2928
3516
  const hasMasks = masks.length > 0;
@@ -2932,11 +3520,12 @@ function ensureFabric() {
2932
3520
  const canUndo = this.historyManager?.canUndo();
2933
3521
  const canRedo = this.historyManager?.canRedo();
2934
3522
  const isInCropMode = !!this._cropMode;
3523
+ const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
2935
3524
 
2936
3525
  if (isInCropMode) {
2937
3526
  // Disable all controls except the crop action buttons while crop mode is active.
2938
3527
  for (const key of Object.keys(this.elements || {})) {
2939
- const element = document.getElementById(this.elements[key]);
3528
+ const element = this._getElement(key);
2940
3529
  if (!element) continue;
2941
3530
  if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
2942
3531
  this._setDisabled(key, false);
@@ -2947,23 +3536,23 @@ function ensureFabric() {
2947
3536
  return;
2948
3537
  }
2949
3538
 
2950
- this._setDisabled('zoomInBtn', !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2951
- this._setDisabled('zoomOutBtn', !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2952
- this._setDisabled('rotateLeftBtn', !hasImage || this.isAnimating);
2953
- this._setDisabled('rotateRightBtn', !hasImage || this.isAnimating);
2954
- this._setDisabled('addMaskBtn', !hasImage || this.isAnimating);
2955
- this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
2956
- this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
2957
- this._setDisabled('mergeBtn', !hasImage || !hasMasks || this.isAnimating);
2958
- this._setDisabled('downloadBtn', !hasImage || this.isAnimating);
2959
- this._setDisabled('resetBtn', !hasImage || isDefaultTransform || this.isAnimating);
2960
- this._setDisabled('undoBtn', !hasImage || this.isAnimating || !canUndo);
2961
- this._setDisabled('redoBtn', !hasImage || this.isAnimating || !canRedo);
2962
- this._setDisabled('cropBtn', !hasImage || this.isAnimating);
3539
+ this._setDisabled('zoomInBtn', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3540
+ this._setDisabled('zoomOutBtn', !hasImage || isBusy || this.currentScale <= this.options.minScale);
3541
+ this._setDisabled('rotateLeftBtn', !hasImage || isBusy);
3542
+ this._setDisabled('rotateRightBtn', !hasImage || isBusy);
3543
+ this._setDisabled('addMaskBtn', !hasImage || isBusy);
3544
+ this._setDisabled('removeMaskBtn', !hasSelectedMask || isBusy);
3545
+ this._setDisabled('removeAllMasksBtn', !hasMasks || isBusy);
3546
+ this._setDisabled('mergeBtn', !hasImage || !hasMasks || isBusy);
3547
+ this._setDisabled('downloadBtn', !hasImage || isBusy);
3548
+ this._setDisabled('resetBtn', !hasImage || isDefaultTransform || isBusy);
3549
+ this._setDisabled('undoBtn', !hasImage || isBusy || !canUndo);
3550
+ this._setDisabled('redoBtn', !hasImage || isBusy || !canRedo);
3551
+ this._setDisabled('cropBtn', !hasImage || isBusy);
2963
3552
  this._setDisabled('applyCropBtn', true);
2964
3553
  this._setDisabled('cancelCropBtn', true);
2965
- this._setDisabled('imageInput', this.isAnimating);
2966
- this._setDisabled('uploadArea', this.isAnimating);
3554
+ this._setDisabled('imageInput', isBusy);
3555
+ this._setDisabled('uploadArea', isBusy);
2967
3556
  }
2968
3557
 
2969
3558
  /**
@@ -2974,19 +3563,23 @@ function ensureFabric() {
2974
3563
  * @private
2975
3564
  */
2976
3565
  _setDisabled(key, disabled) {
2977
- const element = document.getElementById(this.elements[key]);
3566
+ const element = this._getElement(key);
2978
3567
  if (!element) return;
2979
3568
  if ('disabled' in element) {
2980
3569
  element.disabled = !!disabled;
2981
3570
  return;
2982
3571
  }
3572
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = new Map();
3573
+ if (!this._elementOriginalPointerEvents.has(key)) {
3574
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || '');
3575
+ }
2983
3576
 
2984
3577
  if (disabled) {
2985
3578
  element.setAttribute('aria-disabled', 'true');
2986
3579
  element.style.pointerEvents = 'none';
2987
3580
  } else {
2988
3581
  element.removeAttribute('aria-disabled');
2989
- element.style.pointerEvents = '';
3582
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? '';
2990
3583
  }
2991
3584
  }
2992
3585
 
@@ -3012,9 +3605,23 @@ function ensureFabric() {
3012
3605
  * @private
3013
3606
  */
3014
3607
  _setPlaceholderVisible(show) {
3015
- if (!this.placeholderElement || !this.containerElement) return;
3016
- this._setElementVisible(this.placeholderElement, show);
3017
- this._setElementVisible(this.containerElement, !show);
3608
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
3609
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
3610
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
3611
+ this._setElementVisible(canvasVisibilityElement, !show);
3612
+ }
3613
+ }
3614
+
3615
+ _getCanvasVisibilityElement() {
3616
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
3617
+ if (
3618
+ this.containerElement &&
3619
+ this.placeholderElement &&
3620
+ (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))
3621
+ ) {
3622
+ return wrapperElement || this.canvasElement;
3623
+ }
3624
+ return this.containerElement || wrapperElement || this.canvasElement;
3018
3625
  }
3019
3626
 
3020
3627
  /**
@@ -3027,9 +3634,37 @@ function ensureFabric() {
3027
3634
  */
3028
3635
  _setElementVisible(element, isVisible) {
3029
3636
  if (!element) return;
3637
+ this._rememberElementVisibility(element);
3030
3638
  element.hidden = !isVisible;
3031
3639
  element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
3032
- if (isVisible && element.classList) element.classList.remove('d-none');
3640
+ if (element.classList) {
3641
+ element.classList.toggle('d-none', !isVisible);
3642
+ }
3643
+ }
3644
+
3645
+ _rememberElementVisibility(element) {
3646
+ if (!element || this._visibilityStateByElement.has(element)) return;
3647
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3648
+ }
3649
+
3650
+ _captureElementVisibility(element) {
3651
+ if (!element) return null;
3652
+ return {
3653
+ hidden: element.hidden,
3654
+ ariaHidden: element.getAttribute('aria-hidden'),
3655
+ className: element.className
3656
+ };
3657
+ }
3658
+
3659
+ _restoreElementVisibility(element, state) {
3660
+ if (!element || !state) return;
3661
+ element.hidden = !!state.hidden;
3662
+ if (state.ariaHidden === null) {
3663
+ element.removeAttribute('aria-hidden');
3664
+ } else {
3665
+ element.setAttribute('aria-hidden', state.ariaHidden);
3666
+ }
3667
+ element.className = state.className || '';
3033
3668
  }
3034
3669
 
3035
3670
  /**
@@ -3038,11 +3673,19 @@ function ensureFabric() {
3038
3673
  * @public
3039
3674
  */
3040
3675
  dispose() {
3676
+ this._disposed = true;
3677
+ this._rejectActiveAnimations(new Error('Editor disposed during animation'));
3678
+ if (this.animationQueue) {
3679
+ this.animationQueue.cancelAll(new Error('Editor disposed'));
3680
+ }
3681
+ this._isLoading = false;
3682
+ this._activeOperationName = null;
3683
+ this._activeOperationToken = null;
3684
+
3041
3685
  // Remove bound DOM event listeners
3042
3686
  try {
3043
- for (const key in (this._handlersByElementKey || {})) {
3044
- const handlers = this._handlersByElementKey[key] || [];
3045
- const element = document.getElementById(this.elements[key]);
3687
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3688
+ const element = this._getElement(key);
3046
3689
  if (!element) continue;
3047
3690
  handlers.forEach(handlerRecord => {
3048
3691
  try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
@@ -3055,8 +3698,25 @@ function ensureFabric() {
3055
3698
  this._cropRect = null;
3056
3699
  }
3057
3700
 
3058
- if (this.containerElement && this._containerOriginalOverflow !== undefined) {
3059
- try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
3701
+ if (this.containerElement && this._containerOriginalOverflow) {
3702
+ try { this._restoreContainerOverflowState(); } catch (error) { void error; }
3703
+ }
3704
+
3705
+ if (this._visibilityStateByElement) {
3706
+ try {
3707
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach(element => {
3708
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3709
+ if (state) this._restoreElementVisibility(element, state);
3710
+ });
3711
+ } catch (error) { void error; }
3712
+ }
3713
+
3714
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
3715
+ try {
3716
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3717
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3718
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
3719
+ } catch (error) { void error; }
3060
3720
  }
3061
3721
 
3062
3722
  if (this.canvas) {
@@ -3066,6 +3726,22 @@ function ensureFabric() {
3066
3726
  this.isImageLoadedToCanvas = false;
3067
3727
  }
3068
3728
  this._handlersByElementKey = {};
3729
+ this._elementCache = {};
3730
+ this._elementOriginalPointerEvents = new Map();
3731
+ this._clearMaskPlacementMemory();
3732
+ this.originalImage = null;
3733
+ this.baseImageScale = 1;
3734
+ this.currentScale = 1;
3735
+ this.currentRotation = 0;
3736
+ this.isAnimating = false;
3737
+ this._isLoading = false;
3738
+ this._cropMode = false;
3739
+ this._cropRect = null;
3740
+ this._cropHandlers = [];
3741
+ this._cropPrevEvented = null;
3742
+ this._prevSelectionSetting = undefined;
3743
+ this._lastContainerViewportSize = null;
3744
+ this._initialized = false;
3069
3745
  }
3070
3746
  }
3071
3747
 
@@ -3118,6 +3794,8 @@ function ensureFabric() {
3118
3794
  * @type {boolean}
3119
3795
  */
3120
3796
  this.isRunning = false;
3797
+ this.currentTask = null;
3798
+ this._generation = 0;
3121
3799
  }
3122
3800
 
3123
3801
  /**
@@ -3128,13 +3806,33 @@ function ensureFabric() {
3128
3806
  */
3129
3807
  async add(animationFn) {
3130
3808
  return new Promise((resolve, reject) => {
3131
- this.animationTasks.push({ animationFn, resolve, reject });
3809
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
3132
3810
  if (!this.isRunning) {
3133
3811
  this._drainQueue();
3134
3812
  }
3135
3813
  });
3136
3814
  }
3137
3815
 
3816
+ isBusy() {
3817
+ return this.isRunning || this.animationTasks.length > 0;
3818
+ }
3819
+
3820
+ cancelAll(reason = new Error('Animation queue cancelled')) {
3821
+ this._generation += 1;
3822
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3823
+ const tasks = [
3824
+ ...(this.currentTask ? [this.currentTask] : []),
3825
+ ...this.animationTasks.splice(0)
3826
+ ];
3827
+ tasks.forEach(task => {
3828
+ if (!task || task.isSettled) return;
3829
+ task.isSettled = true;
3830
+ task.reject(cancellationError);
3831
+ });
3832
+ this.isRunning = false;
3833
+ this.currentTask = null;
3834
+ }
3835
+
3138
3836
  /**
3139
3837
  * Runs queued animation tasks sequentially until the queue is empty.
3140
3838
  *
@@ -3142,22 +3840,36 @@ function ensureFabric() {
3142
3840
  * @returns {Promise<void>}
3143
3841
  */
3144
3842
  async _drainQueue() {
3145
- if (this.animationTasks.length === 0) {
3146
- this.isRunning = false;
3147
- return;
3148
- }
3149
-
3843
+ if (this.isRunning) return;
3844
+ const generation = this._generation;
3150
3845
  this.isRunning = true;
3151
- const { animationFn, resolve, reject } = this.animationTasks.shift();
3152
3846
 
3153
3847
  try {
3154
- const result = await animationFn();
3155
- resolve(result);
3156
- } catch (error) {
3157
- reject(error);
3158
- }
3848
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3849
+ const task = this.animationTasks.shift();
3850
+ this.currentTask = task;
3159
3851
 
3160
- await this._drainQueue();
3852
+ try {
3853
+ const result = await task.animationFn();
3854
+ if (generation === this._generation && !task.isSettled) {
3855
+ task.isSettled = true;
3856
+ task.resolve(result);
3857
+ }
3858
+ } catch (error) {
3859
+ if (generation === this._generation && !task.isSettled) {
3860
+ task.isSettled = true;
3861
+ task.reject(error);
3862
+ }
3863
+ } finally {
3864
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3865
+ }
3866
+ }
3867
+ } finally {
3868
+ if (generation === this._generation) {
3869
+ this.isRunning = false;
3870
+ this.currentTask = null;
3871
+ }
3872
+ }
3161
3873
  }
3162
3874
  }
3163
3875
 
@@ -3213,16 +3925,8 @@ function ensureFabric() {
3213
3925
  * @private
3214
3926
  */
3215
3927
  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;
3928
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3929
+ this.pending = nextTask.catch(() => undefined);
3226
3930
  return nextTask;
3227
3931
  }
3228
3932
 
@@ -3234,8 +3938,14 @@ function ensureFabric() {
3234
3938
  * @returns {void}
3235
3939
  */
3236
3940
  execute(command) {
3237
- command.execute();
3941
+ const result = command.execute();
3942
+ if (result && typeof result.then === 'function') {
3943
+ return Promise.resolve(result).then(() => {
3944
+ this.push(command);
3945
+ });
3946
+ }
3238
3947
  this.push(command);
3948
+ return result;
3239
3949
  }
3240
3950
 
3241
3951
  /**
@@ -3255,9 +3965,8 @@ function ensureFabric() {
3255
3965
 
3256
3966
  if (this.history.length > this.maxSize) {
3257
3967
  this.history.shift();
3258
- } else {
3259
- this.currentIndex++;
3260
3968
  }
3969
+ this.currentIndex = this.history.length - 1;
3261
3970
  }
3262
3971
 
3263
3972
  /**