@bensitu/image-editor 1.4.0 → 1.4.2

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.4.0
4
+ * @version 1.4.2
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.for('ImageEditorInternalOperation');
11
12
 
12
13
  /**
13
14
  * Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
@@ -252,12 +253,16 @@ function ensureFabric() {
252
253
  this.currentRotation = 0;
253
254
  this.maskCounter = 0;
254
255
  this.isAnimating = false;
256
+ this._isLoading = false;
257
+ this._activeOperationName = null;
258
+ this._activeOperationToken = null;
255
259
  this.elements = {};
256
260
  this.isImageLoadedToCanvas = false;
257
261
  this.maxHistorySize = 50;
258
262
 
259
263
  this._handlersByElementKey = {};
260
264
  this._elementCache = {};
265
+ this._elementOriginalPointerEvents = new Map();
261
266
 
262
267
  this._lastMask = null;
263
268
  this._lastMaskInitialLeft = null;
@@ -357,6 +362,10 @@ function ensureFabric() {
357
362
  this.historyManager = new HistoryManager(this.maxHistorySize);
358
363
  this._visibilityStateByElement = new WeakMap();
359
364
  this._activeAnimationRejectors = new Set();
365
+ this._isLoading = false;
366
+ this._activeOperationName = null;
367
+ this._activeOperationToken = null;
368
+ this._elementOriginalPointerEvents = new Map();
360
369
  this._containerOriginalOverflow = null;
361
370
  this._lastContainerViewportSize = null;
362
371
  this._canvasElementOriginalStyle = null;
@@ -589,6 +598,13 @@ function ensureFabric() {
589
598
  this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
590
599
  }
591
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
+
592
608
  /**
593
609
  * DOM / UI bindings
594
610
  * @private
@@ -740,8 +756,10 @@ function ensureFabric() {
740
756
  if (!this._fabricLoaded) return;
741
757
  if (!this.canvas || this._disposed) return;
742
758
  if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
743
- this._assertIdleForOperation('loadImage');
759
+ this._assertIdleForOperation('loadImage', options);
744
760
 
761
+ this._isLoading = true;
762
+ this._updateUI();
745
763
  this._warnOnImageLayoutOptionConflict();
746
764
  const transaction = this._captureLoadImageTransaction();
747
765
 
@@ -750,14 +768,16 @@ function ensureFabric() {
750
768
  if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
751
769
 
752
770
  let loadSource = imageBase64;
753
- if (this.options.downsampleOnLoad) {
771
+ const downsampleMaxWidth = Number(this.options.downsampleMaxWidth);
772
+ const downsampleMaxHeight = Number(this.options.downsampleMaxHeight);
773
+ if (this.options.downsampleOnLoad && downsampleMaxWidth > 0 && downsampleMaxHeight > 0) {
754
774
  const shouldResize =
755
- imageElement.naturalWidth > this.options.downsampleMaxWidth ||
756
- imageElement.naturalHeight > this.options.downsampleMaxHeight;
775
+ imageElement.naturalWidth > downsampleMaxWidth ||
776
+ imageElement.naturalHeight > downsampleMaxHeight;
757
777
  if (shouldResize) {
758
778
  const ratio = Math.min(
759
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
760
- this.options.downsampleMaxHeight / imageElement.naturalHeight
779
+ downsampleMaxWidth / imageElement.naturalWidth,
780
+ downsampleMaxHeight / imageElement.naturalHeight
761
781
  );
762
782
  const targetWidth = Math.round(imageElement.naturalWidth * ratio);
763
783
  const targetHeight = Math.round(imageElement.naturalHeight * ratio);
@@ -765,10 +785,12 @@ function ensureFabric() {
765
785
  imageElement,
766
786
  targetWidth,
767
787
  targetHeight,
768
- this.options.downsampleQuality,
788
+ this._normalizeQuality(this.options.downsampleQuality),
769
789
  imageBase64
770
790
  );
771
791
  }
792
+ } else if (this.options.downsampleOnLoad) {
793
+ this._reportWarning('loadImage: downsample limits must be positive numbers; using the original image');
772
794
  }
773
795
 
774
796
  const fabricImage = await this._createFabricImageFromURL(loadSource);
@@ -844,6 +866,9 @@ function ensureFabric() {
844
866
  } catch (error) {
845
867
  await this._rollbackLoadImageTransaction(transaction);
846
868
  throw error;
869
+ } finally {
870
+ this._isLoading = false;
871
+ if (!this._disposed && this.canvas) this._updateUI();
847
872
  }
848
873
  }
849
874
 
@@ -862,6 +887,22 @@ function ensureFabric() {
862
887
  );
863
888
  }
864
889
 
890
+ /**
891
+ * Checks whether the editor is in a temporary non-mutating state.
892
+ *
893
+ * @returns {boolean} True while loading, animating, cropping, or running a compound operation.
894
+ * @public
895
+ */
896
+ isBusy() {
897
+ return !!(
898
+ this.isAnimating ||
899
+ this._cropMode ||
900
+ this._isLoading ||
901
+ this._activeOperationToken ||
902
+ (this.animationQueue && this.animationQueue.isBusy())
903
+ );
904
+ }
905
+
865
906
  /**
866
907
  * Creates an HTMLImageElement from a given data URL.
867
908
  *
@@ -936,7 +977,6 @@ function ensureFabric() {
936
977
  _captureLoadImageTransaction() {
937
978
  return {
938
979
  canvasState: this._serializeCanvasState(),
939
- originalImage: this.originalImage,
940
980
  baseImageScale: this.baseImageScale,
941
981
  currentScale: this.currentScale,
942
982
  currentRotation: this.currentRotation,
@@ -961,9 +1001,14 @@ function ensureFabric() {
961
1001
 
962
1002
  async _rollbackLoadImageTransaction(transaction) {
963
1003
  if (!transaction || !this.canvas || this._disposed) return;
1004
+ let didRestoreCanvasState = false;
964
1005
  try {
965
- if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
1006
+ if (transaction.canvasState) {
1007
+ await this.loadFromState(transaction.canvasState);
1008
+ didRestoreCanvasState = true;
1009
+ }
966
1010
  } catch (error) {
1011
+ this._lastMask = null;
967
1012
  this._reportError('loadImage rollback failed', error);
968
1013
  }
969
1014
 
@@ -973,16 +1018,20 @@ function ensureFabric() {
973
1018
  this.maskCounter = transaction.maskCounter;
974
1019
  this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
975
1020
  this._lastSnapshot = transaction.lastSnapshot;
1021
+ if (didRestoreCanvasState) {
1022
+ this._restoreLastMaskReference(transaction.lastMask);
1023
+ } else {
1024
+ this._lastMask = null;
1025
+ }
976
1026
  this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
977
1027
  this._lastMaskInitialTop = transaction.lastMaskInitialTop;
978
1028
  this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
979
- this._containerOriginalOverflow = transaction.containerOverflow;
980
1029
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
981
1030
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
982
1031
  if (this.containerElement) {
983
1032
  this.containerElement.scrollLeft = transaction.scrollLeft;
984
1033
  this.containerElement.scrollTop = transaction.scrollTop;
985
- this._restoreContainerOverflowState();
1034
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
986
1035
  }
987
1036
  this._updateInputs();
988
1037
  this._updateMaskList();
@@ -990,6 +1039,22 @@ function ensureFabric() {
990
1039
  if (this.canvas) this.canvas.renderAll();
991
1040
  }
992
1041
 
1042
+ _restoreLastMaskReference(previousLastMask) {
1043
+ if (!this.canvas) {
1044
+ this._lastMask = null;
1045
+ return;
1046
+ }
1047
+
1048
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1049
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
1050
+ this._lastMask = masks.find(mask => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
1051
+ if (!this._lastMask) {
1052
+ this._lastMaskInitialLeft = null;
1053
+ this._lastMaskInitialTop = null;
1054
+ this._lastMaskInitialWidth = null;
1055
+ }
1056
+ }
1057
+
993
1058
  /**
994
1059
  * Resamples the given image element to a new width and height and returns the result as a data URL.
995
1060
  *
@@ -1002,12 +1067,20 @@ function ensureFabric() {
1002
1067
  * @private
1003
1068
  */
1004
1069
  _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
1070
+ const sourceWidth = Math.max(1, Number(imageElement && (imageElement.naturalWidth || imageElement.width)) || 0);
1071
+ const sourceHeight = Math.max(1, Number(imageElement && (imageElement.naturalHeight || imageElement.height)) || 0);
1072
+ const safeTargetWidth = Math.round(Number(targetWidth));
1073
+ const safeTargetHeight = Math.round(Number(targetHeight));
1074
+ if (!Number.isFinite(safeTargetWidth) || !Number.isFinite(safeTargetHeight) || safeTargetWidth <= 0 || safeTargetHeight <= 0) {
1075
+ throw new Error('Invalid image resample target dimensions');
1076
+ }
1077
+
1005
1078
  const offscreenCanvas = document.createElement('canvas');
1006
- offscreenCanvas.width = targetWidth;
1007
- offscreenCanvas.height = targetHeight;
1079
+ offscreenCanvas.width = safeTargetWidth;
1080
+ offscreenCanvas.height = safeTargetHeight;
1008
1081
  const context = offscreenCanvas.getContext('2d');
1009
1082
  if (!context) throw new Error('2D canvas context is unavailable');
1010
- context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
1083
+ context.drawImage(imageElement, 0, 0, sourceWidth, sourceHeight, 0, 0, safeTargetWidth, safeTargetHeight);
1011
1084
  return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
1012
1085
  }
1013
1086
 
@@ -1315,7 +1388,11 @@ function ensureFabric() {
1315
1388
  maskStyleBackups.push(backup);
1316
1389
  mask.set(stylePatch);
1317
1390
  });
1318
- return callback();
1391
+ const result = callback();
1392
+ if (result && typeof result.then === 'function') {
1393
+ throw new Error('_withNormalizedMaskStyles callback must be synchronous');
1394
+ }
1395
+ return result;
1319
1396
  } finally {
1320
1397
  maskStyleBackups.forEach(backup => {
1321
1398
  try {
@@ -1387,9 +1464,15 @@ function ensureFabric() {
1387
1464
  * @returns {number} A finite quality value between 0 and 1.
1388
1465
  * @private
1389
1466
  */
1390
- _normalizeQuality(quality) {
1467
+ _normalizeQuality(quality, fallback = undefined) {
1468
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1469
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1470
+ const safeFallback = Number.isFinite(numericFallback)
1471
+ ? Math.max(0, Math.min(1, numericFallback))
1472
+ : 0.92;
1473
+ if (quality == null) return safeFallback;
1391
1474
  const numericQuality = Number(quality);
1392
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1475
+ if (!Number.isFinite(numericQuality)) return safeFallback;
1393
1476
  return Math.max(0, Math.min(1, numericQuality));
1394
1477
  }
1395
1478
 
@@ -1444,62 +1527,70 @@ function ensureFabric() {
1444
1527
  };
1445
1528
  }
1446
1529
 
1447
- /**
1448
- * Crops an image data URL to a source region using an offscreen canvas.
1449
- *
1450
- * @param {string} dataUrl - Source image data URL.
1451
- * @param {number} sourceX - Source region x coordinate.
1452
- * @param {number} sourceY - Source region y coordinate.
1453
- * @param {number} sourceWidth - Source region width.
1454
- * @param {number} sourceHeight - Source region height.
1455
- * @param {number} multiplier - Export multiplier already applied to the source data URL.
1456
- * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1457
- * @param {number} [quality=0.92] - Output image quality for lossy formats.
1458
- * @returns {Promise<string>} Resolves with the cropped image data URL.
1459
- * @private
1460
- */
1461
- async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1462
- return new Promise((resolve, reject) => {
1463
- const imageElement = new Image();
1464
- let isSettled = false;
1465
- const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1466
- const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
1467
- let timerId;
1468
- const settle = (callback) => {
1469
- if (isSettled) return;
1470
- isSettled = true;
1471
- clearTimeout(timerId);
1472
- imageElement.onload = null;
1473
- imageElement.onerror = null;
1474
- callback();
1475
- };
1476
- timerId = setTimeout(() => {
1477
- settle(() => reject(new Error('Image crop load timed out')));
1478
- // Clearing src prevents later network/decode work; already-dispatched onload work is guarded by settle().
1479
- try { imageElement.src = ''; } catch (error) { void error; }
1480
- }, safeTimeoutMs);
1481
- imageElement.onload = () => {
1482
- try {
1483
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1484
- const scaledSourceX = Math.round(sourceX * safeMultiplier);
1485
- const scaledSourceY = Math.round(sourceY * safeMultiplier);
1486
- const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1487
- const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1488
- const offscreenCanvas = document.createElement('canvas');
1489
- offscreenCanvas.width = scaledSourceWidth;
1490
- offscreenCanvas.height = scaledSourceHeight;
1491
- const context = offscreenCanvas.getContext('2d');
1492
- if (!context) throw new Error('2D canvas context is unavailable');
1493
-
1494
- context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1495
- settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1496
- } catch (error) {
1497
- settle(() => reject(error));
1498
- }
1499
- };
1500
- imageElement.onerror = (error) => settle(() => reject(error));
1501
- imageElement.src = dataUrl;
1502
- });
1530
+ _hasFractionalCanvasEdge(value) {
1531
+ const numericValue = Number(value);
1532
+ if (!Number.isFinite(numericValue)) return false;
1533
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1534
+ }
1535
+
1536
+ _getPartialExportEdges(bounds) {
1537
+ if (!bounds) return null;
1538
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1539
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1540
+ if (!isAxisAligned) return null;
1541
+
1542
+ return {
1543
+ left: this._hasFractionalCanvasEdge(bounds.left),
1544
+ top: this._hasFractionalCanvasEdge(bounds.top),
1545
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1546
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1547
+ };
1548
+ }
1549
+
1550
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1551
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1552
+
1553
+ const imageElement = await this._createImageElement(dataUrl);
1554
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1555
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1556
+ const offscreenCanvas = document.createElement('canvas');
1557
+ offscreenCanvas.width = width;
1558
+ offscreenCanvas.height = height;
1559
+ const context = offscreenCanvas.getContext('2d');
1560
+ if (!context) throw new Error('2D canvas context is unavailable');
1561
+ context.drawImage(imageElement, 0, 0, width, height);
1562
+
1563
+ const imageData = context.getImageData(0, 0, width, height);
1564
+ const pixels = imageData.data;
1565
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1566
+ const index = (y * width + x) * 4;
1567
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1568
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1569
+ pixels[index] = pixels[fallbackIndex];
1570
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1571
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1572
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1573
+ }
1574
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1575
+ pixels[index + 3] = 255;
1576
+ }
1577
+ };
1578
+
1579
+ if (edges.left && width > 1) {
1580
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1581
+ }
1582
+ if (edges.right && width > 1) {
1583
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1584
+ }
1585
+ if (edges.top && height > 1) {
1586
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1587
+ }
1588
+ if (edges.bottom && height > 1) {
1589
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1590
+ }
1591
+
1592
+ context.putImageData(imageData, 0, 0);
1593
+ return offscreenCanvas.toDataURL('image/png');
1503
1594
  }
1504
1595
 
1505
1596
  /**
@@ -1513,13 +1604,16 @@ function ensureFabric() {
1513
1604
  * @param {number} [region.multiplier=1] - Export multiplier.
1514
1605
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1515
1606
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1607
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1516
1608
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1517
1609
  * @private
1518
1610
  */
1519
- _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1611
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
1520
1612
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1521
- return this.canvas.toDataURL({
1522
- format,
1613
+ const safeFormat = this._normalizeImageFormat(format);
1614
+ const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1615
+ let regionDataUrl = this.canvas.toDataURL({
1616
+ format: exportFormat,
1523
1617
  quality,
1524
1618
  multiplier: safeMultiplier,
1525
1619
  left: sourceX,
@@ -1527,6 +1621,71 @@ function ensureFabric() {
1527
1621
  width: sourceWidth,
1528
1622
  height: sourceHeight
1529
1623
  });
1624
+
1625
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1626
+ if (safeFormat !== 'jpeg') return regionDataUrl;
1627
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1628
+ }
1629
+
1630
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1631
+ const imageElement = await this._createImageElement(dataUrl);
1632
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1633
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1634
+ const offscreenCanvas = document.createElement('canvas');
1635
+ offscreenCanvas.width = width;
1636
+ offscreenCanvas.height = height;
1637
+ const context = offscreenCanvas.getContext('2d');
1638
+ if (!context) throw new Error('2D canvas context is unavailable');
1639
+ context.fillStyle = this._getJpegBackgroundColor();
1640
+ context.fillRect(0, 0, width, height);
1641
+ context.drawImage(imageElement, 0, 0, width, height);
1642
+ return offscreenCanvas.toDataURL('image/jpeg', this._normalizeQuality(quality));
1643
+ }
1644
+
1645
+ _getJpegBackgroundColor() {
1646
+ const backgroundColor = String(this.options.backgroundColor || '').trim();
1647
+ if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return '#ffffff';
1648
+ return backgroundColor;
1649
+ }
1650
+
1651
+ _isTransparentCssColor(color) {
1652
+ const normalizedColor = String(color || '').trim().toLowerCase();
1653
+ if (!normalizedColor || normalizedColor === 'transparent') return true;
1654
+
1655
+ const hexAlphaMatch = normalizedColor.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/i);
1656
+ if (hexAlphaMatch) {
1657
+ const alpha = hexAlphaMatch[1] || hexAlphaMatch[2];
1658
+ return alpha === '0' || alpha === '00';
1659
+ }
1660
+
1661
+ const slashAlphaMatch = normalizedColor.match(/^(?:rgba?|hsla?)\([^)]*\/\s*([^)]+)\)$/i);
1662
+ if (slashAlphaMatch) return this._isZeroCssAlpha(slashAlphaMatch[1]);
1663
+
1664
+ const commaAlphaMatch = normalizedColor.match(/^(?:rgba|hsla)\((.*)\)$/i);
1665
+ if (commaAlphaMatch) {
1666
+ const parts = commaAlphaMatch[1].split(',');
1667
+ if (parts.length >= 4) return this._isZeroCssAlpha(parts[parts.length - 1]);
1668
+ }
1669
+
1670
+ return false;
1671
+ }
1672
+
1673
+ _isZeroCssAlpha(alphaValue) {
1674
+ const normalizedAlpha = String(alphaValue || '').trim();
1675
+ if (!normalizedAlpha) return false;
1676
+ if (normalizedAlpha.endsWith('%')) return Number.parseFloat(normalizedAlpha) === 0;
1677
+ return Number(normalizedAlpha) === 0;
1678
+ }
1679
+
1680
+ _decodeBase64Payload(base64Payload) {
1681
+ const payload = String(base64Payload || '');
1682
+ if (typeof atob === 'function') {
1683
+ return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
1684
+ }
1685
+ if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
1686
+ return new Uint8Array(Buffer.from(payload, 'base64'));
1687
+ }
1688
+ throw new Error('Base64 decoding is unavailable');
1530
1689
  }
1531
1690
 
1532
1691
  /**
@@ -1698,19 +1857,80 @@ function ensureFabric() {
1698
1857
  * @public
1699
1858
  */
1700
1859
  scaleImage(factor, options = {}) {
1701
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1860
+ try {
1861
+ this._assertCanQueueAnimation('scaleImage', options);
1862
+ } catch (error) {
1863
+ return Promise.reject(error);
1864
+ }
1865
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
1866
+ .finally(() => {
1867
+ if (!this._disposed && this.canvas) this._updateUI();
1868
+ });
1869
+ }
1870
+
1871
+ _getInternalOperationToken(options) {
1872
+ return options && options[INTERNAL_OPERATION_TOKEN];
1702
1873
  }
1703
1874
 
1704
- _assertIdleForOperation(operationName) {
1875
+ _isOwnInternalOperation(options) {
1876
+ const token = this._getInternalOperationToken(options);
1877
+ return !!token && token === this._activeOperationToken;
1878
+ }
1879
+
1880
+ _beginBusyOperation(operationName) {
1881
+ const token = Symbol(operationName);
1882
+ this._activeOperationName = operationName;
1883
+ this._activeOperationToken = token;
1884
+ this._updateUI();
1885
+ return token;
1886
+ }
1887
+
1888
+ _endBusyOperation(token) {
1889
+ if (token && token === this._activeOperationToken) {
1890
+ this._activeOperationName = null;
1891
+ this._activeOperationToken = null;
1892
+ this._updateUI();
1893
+ }
1894
+ }
1895
+
1896
+ _withInternalOperationOptions(token, options = {}) {
1897
+ return {
1898
+ ...options,
1899
+ [INTERNAL_OPERATION_TOKEN]: token
1900
+ };
1901
+ }
1902
+
1903
+ _assertEditorAvailable(operationName) {
1705
1904
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1905
+ }
1906
+
1907
+ _assertIdleForOperation(operationName, options = {}) {
1908
+ this._assertEditorAvailable(operationName);
1909
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1706
1910
  if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1707
1911
  throw new Error(`${operationName} cannot run while an animation is running`);
1708
1912
  }
1913
+ if (this._isLoading && !isOwnInternalOperation) {
1914
+ throw new Error(`${operationName} cannot run while an image is loading`);
1915
+ }
1916
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1917
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1918
+ }
1919
+ }
1920
+
1921
+ _assertCanQueueAnimation(operationName, options = {}) {
1922
+ this._assertEditorAvailable(operationName);
1923
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1924
+ throw new Error(`${operationName} cannot run while an image is loading`);
1925
+ }
1926
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1927
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
1928
+ }
1709
1929
  }
1710
1930
 
1711
- _canMutateNow(operationName) {
1931
+ _canMutateNow(operationName, options = {}) {
1712
1932
  try {
1713
- this._assertIdleForOperation(operationName);
1933
+ this._assertIdleForOperation(operationName, options);
1714
1934
  return true;
1715
1935
  } catch (error) {
1716
1936
  this._reportError(`${operationName} blocked`, error);
@@ -1824,7 +2044,15 @@ function ensureFabric() {
1824
2044
  * @public
1825
2045
  */
1826
2046
  rotateImage(degrees, options = {}) {
1827
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
2047
+ try {
2048
+ this._assertCanQueueAnimation('rotateImage', options);
2049
+ } catch (error) {
2050
+ return Promise.reject(error);
2051
+ }
2052
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
2053
+ .finally(() => {
2054
+ if (!this._disposed && this.canvas) this._updateUI();
2055
+ });
1828
2056
  }
1829
2057
 
1830
2058
  /**
@@ -1894,6 +2122,11 @@ function ensureFabric() {
1894
2122
  */
1895
2123
  resetImageTransform() {
1896
2124
  if (!this.originalImage) return Promise.resolve();
2125
+ try {
2126
+ this._assertCanQueueAnimation('resetImageTransform');
2127
+ } catch (error) {
2128
+ return Promise.reject(error);
2129
+ }
1897
2130
 
1898
2131
  return this.animationQueue.add(async () => {
1899
2132
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
@@ -1901,6 +2134,8 @@ function ensureFabric() {
1901
2134
  await this._rotateImageImpl(0, { saveHistory: false });
1902
2135
  const after = this._captureCanvasStateOrThrow('resetImageTransform');
1903
2136
  this._pushStateTransition(before, after);
2137
+ }).finally(() => {
2138
+ if (!this._disposed && this.canvas) this._updateUI();
1904
2139
  }).catch(error => {
1905
2140
  this._reportError('resetImageTransform() failed', error);
1906
2141
  throw error;
@@ -1942,6 +2177,13 @@ function ensureFabric() {
1942
2177
  ? JSON.parse(serializedState)
1943
2178
  : serializedState;
1944
2179
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
2180
+ if (
2181
+ editorMetadata &&
2182
+ Object.prototype.hasOwnProperty.call(editorMetadata, 'version') &&
2183
+ Number(editorMetadata.version) !== 1
2184
+ ) {
2185
+ this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
2186
+ }
1945
2187
 
1946
2188
  this.canvas.loadFromJSON(state, async () => {
1947
2189
  try {
@@ -2035,22 +2277,48 @@ function ensureFabric() {
2035
2277
 
2036
2278
  _waitForImageElementReady(imageElement) {
2037
2279
  if (!imageElement) return Promise.resolve();
2038
- if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
2280
+ const hasLoadedDimensions = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) &&
2281
+ (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2282
+ if (hasLoadedDimensions) return Promise.resolve();
2283
+ if (imageElement.complete) return Promise.reject(new Error('Image could not be loaded while restoring state'));
2039
2284
  return new Promise((resolve, reject) => {
2040
2285
  let isSettled = false;
2041
- const timerId = setTimeout(() => {
2042
- settle(() => reject(new Error('Image load timed out while restoring state')));
2043
- }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2286
+ let timerId;
2044
2287
  const settle = (callback) => {
2045
2288
  if (isSettled) return;
2046
2289
  isSettled = true;
2047
2290
  clearTimeout(timerId);
2048
- imageElement.onload = null;
2049
- imageElement.onerror = null;
2291
+ if (typeof imageElement.removeEventListener === 'function') {
2292
+ imageElement.removeEventListener('load', handleLoad);
2293
+ imageElement.removeEventListener('error', handleError);
2294
+ } else {
2295
+ imageElement.onload = null;
2296
+ imageElement.onerror = null;
2297
+ }
2050
2298
  callback();
2051
2299
  };
2052
- imageElement.onload = () => settle(resolve);
2053
- imageElement.onerror = (error) => settle(() => reject(error));
2300
+ const handleLoad = () => {
2301
+ const didLoad = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) &&
2302
+ (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
2303
+ settle(() => {
2304
+ if (didLoad) {
2305
+ resolve();
2306
+ } else {
2307
+ reject(new Error('Image could not be loaded while restoring state'));
2308
+ }
2309
+ });
2310
+ };
2311
+ const handleError = (error) => settle(() => reject(error instanceof Error ? error : new Error('Image could not be loaded while restoring state')));
2312
+ timerId = setTimeout(() => {
2313
+ settle(() => reject(new Error('Image load timed out while restoring state')));
2314
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
2315
+ if (typeof imageElement.addEventListener === 'function') {
2316
+ imageElement.addEventListener('load', handleLoad, { once: true });
2317
+ imageElement.addEventListener('error', handleError, { once: true });
2318
+ } else {
2319
+ imageElement.onload = handleLoad;
2320
+ imageElement.onerror = handleError;
2321
+ }
2054
2322
  });
2055
2323
  }
2056
2324
 
@@ -2336,6 +2604,11 @@ function ensureFabric() {
2336
2604
  }
2337
2605
  }
2338
2606
 
2607
+ if (!mask || typeof mask.set !== 'function' || typeof mask.setCoords !== 'function') {
2608
+ this._reportWarning('fabricGenerator returned an invalid Fabric object');
2609
+ return null;
2610
+ }
2611
+
2339
2612
  const styles = maskConfig.styles || {};
2340
2613
  const hasStyle = property => Object.prototype.hasOwnProperty.call(styles, property);
2341
2614
  const maskSettings = {
@@ -2435,7 +2708,7 @@ function ensureFabric() {
2435
2708
  */
2436
2709
  removeAllMasks(options = {}) {
2437
2710
  if (!this.canvas) return;
2438
- if (!this._canMutateNow('removeAllMasks')) return;
2711
+ if (!this._canMutateNow('removeAllMasks', options)) return;
2439
2712
  const saveHistory = options.saveHistory !== false;
2440
2713
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2441
2714
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -2470,6 +2743,99 @@ function ensureFabric() {
2470
2743
  }
2471
2744
  }
2472
2745
 
2746
+ _captureMaskLabelBackups(masks) {
2747
+ if (!this.canvas) return [];
2748
+ const canvasObjects = new Set(this.canvas.getObjects());
2749
+ return (masks || []).map(mask => {
2750
+ const label = mask && mask.__label ? mask.__label : null;
2751
+ return {
2752
+ mask,
2753
+ label,
2754
+ hadLabel: !!label,
2755
+ labelInCanvas: !!label && canvasObjects.has(label),
2756
+ visible: label ? label.visible : undefined
2757
+ };
2758
+ });
2759
+ }
2760
+
2761
+ _restoreMaskLabelBackups(labelBackups) {
2762
+ if (!this.canvas || !Array.isArray(labelBackups)) return;
2763
+ const canvasObjects = new Set(this.canvas.getObjects());
2764
+ labelBackups.forEach(backup => {
2765
+ if (!backup || !backup.mask) return;
2766
+ try {
2767
+ if (!backup.hadLabel) {
2768
+ if (backup.mask.__label) this._removeLabelForMask(backup.mask);
2769
+ return;
2770
+ }
2771
+ backup.mask.__label = backup.label;
2772
+ if (!backup.label) return;
2773
+ if (backup.labelInCanvas && !canvasObjects.has(backup.label)) {
2774
+ this.canvas.add(backup.label);
2775
+ canvasObjects.add(backup.label);
2776
+ }
2777
+ if (backup.visible !== undefined) backup.label.set({ visible: backup.visible });
2778
+ if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2779
+ this._syncMaskLabel(backup.mask);
2780
+ } catch (error) { void error; }
2781
+ });
2782
+ }
2783
+
2784
+ _captureActiveObjectBackup() {
2785
+ if (!this.canvas) return null;
2786
+ const activeObject = this.canvas.getActiveObject();
2787
+ if (!activeObject) return null;
2788
+ const selectedObjects = typeof activeObject.getObjects === 'function'
2789
+ ? activeObject.getObjects()
2790
+ : [activeObject];
2791
+ return { activeObject, selectedObjects };
2792
+ }
2793
+
2794
+ _restoreActiveObjectBackup(activeObjectBackup) {
2795
+ if (!this.canvas || !activeObjectBackup || !activeObjectBackup.activeObject) return;
2796
+ const canvasObjects = this.canvas.getObjects();
2797
+ const selectedObjects = Array.isArray(activeObjectBackup.selectedObjects)
2798
+ ? activeObjectBackup.selectedObjects
2799
+ : [];
2800
+ const canRestore = selectedObjects.length
2801
+ ? selectedObjects.every(object => canvasObjects.includes(object))
2802
+ : canvasObjects.includes(activeObjectBackup.activeObject);
2803
+ if (!canRestore) return;
2804
+ try {
2805
+ this.canvas.setActiveObject(activeObjectBackup.activeObject);
2806
+ } catch (error) { void error; }
2807
+ }
2808
+
2809
+ _captureMaskExportBackups(masks) {
2810
+ return (masks || []).map(mask => ({
2811
+ object: mask,
2812
+ visible: mask.visible,
2813
+ opacity: mask.opacity,
2814
+ fill: mask.fill,
2815
+ strokeWidth: mask.strokeWidth,
2816
+ stroke: mask.stroke,
2817
+ selectable: mask.selectable,
2818
+ lockRotation: mask.lockRotation
2819
+ }));
2820
+ }
2821
+
2822
+ _restoreMaskExportBackups(maskBackups) {
2823
+ (maskBackups || []).forEach(backup => {
2824
+ try {
2825
+ backup.object.set({
2826
+ visible: backup.visible,
2827
+ opacity: backup.opacity,
2828
+ fill: backup.fill,
2829
+ strokeWidth: backup.strokeWidth,
2830
+ stroke: backup.stroke,
2831
+ selectable: backup.selectable,
2832
+ lockRotation: backup.lockRotation
2833
+ });
2834
+ backup.object.setCoords();
2835
+ } catch (error) { void error; }
2836
+ });
2837
+ }
2838
+
2473
2839
  /**
2474
2840
  * Returns a stable zero-based creation index for label callbacks.
2475
2841
  *
@@ -2547,10 +2913,14 @@ function ensureFabric() {
2547
2913
  _hideAllMaskLabels() {
2548
2914
  if (!this.canvas) return;
2549
2915
  const canvasObjects = this.canvas.getObjects();
2916
+ const canvasObjectSet = new Set(canvasObjects);
2550
2917
  const labels = canvasObjects.filter(object => object.maskLabel);
2551
2918
  labels.forEach(label => {
2552
2919
  try {
2553
- if (canvasObjects.includes(label)) this.canvas.remove(label);
2920
+ if (canvasObjectSet.has(label)) {
2921
+ this.canvas.remove(label);
2922
+ canvasObjectSet.delete(label);
2923
+ }
2554
2924
  } catch (error) { void error; }
2555
2925
  });
2556
2926
  canvasObjects.forEach(object => {
@@ -2714,20 +3084,38 @@ function ensureFabric() {
2714
3084
  this._assertIdleForOperation('mergeMasks');
2715
3085
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2716
3086
  if (!masks.length) return;
3087
+ const beforeJson = this._serializeCanvasState();
3088
+ const operationToken = this._beginBusyOperation('mergeMasks');
2717
3089
 
2718
3090
  this.canvas.discardActiveObject();
2719
3091
  this.canvas.renderAll();
2720
3092
 
2721
3093
  try {
2722
- const beforeJson = this._serializeCanvasState();
2723
- const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2724
- this.removeAllMasks({ saveHistory: false });
2725
- await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
3094
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
3095
+ exportImageArea: true,
3096
+ multiplier: this.options.exportMultiplier,
3097
+ fileType: 'png'
3098
+ }));
3099
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
3100
+ if (this.canvas.getObjects().some(object => object.maskId)) {
3101
+ throw new Error('Masks could not be removed during merge');
3102
+ }
3103
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
3104
+ preserveScroll: true,
3105
+ resetMaskCounter: false
3106
+ }));
2726
3107
  const afterJson = this._serializeCanvasState();
2727
3108
  this._pushStateTransition(beforeJson, afterJson);
2728
3109
  } catch (error) {
2729
3110
  this._reportError('merge error', error);
3111
+ try {
3112
+ await this.loadFromState(beforeJson);
3113
+ } catch (restoreError) {
3114
+ this._reportError('mergeMasks rollback failed', restoreError);
3115
+ }
2730
3116
  throw error;
3117
+ } finally {
3118
+ this._endBusyOperation(operationToken);
2731
3119
  }
2732
3120
  }
2733
3121
 
@@ -2783,7 +3171,7 @@ function ensureFabric() {
2783
3171
  */
2784
3172
  async exportImageBase64(options = {}) {
2785
3173
  if (!this.originalImage) throw new Error('No image loaded');
2786
- this._assertIdleForOperation('exportImageBase64');
3174
+ this._assertIdleForOperation('exportImageBase64', options);
2787
3175
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2788
3176
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2789
3177
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2791,7 +3179,11 @@ function ensureFabric() {
2791
3179
 
2792
3180
  if (!exportImageArea) {
2793
3181
  const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
3182
+ const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
2794
3183
  const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
3184
+ const maskStyleBackups = this._captureMaskExportBackups(editableMasks);
3185
+ const labelBackups = this._captureMaskLabelBackups(editableMasks);
3186
+ const activeObjectBackup = this._captureActiveObjectBackup();
2795
3187
 
2796
3188
  try {
2797
3189
  masks.forEach(mask => { mask.set({ visible: false }); });
@@ -2800,32 +3192,30 @@ function ensureFabric() {
2800
3192
 
2801
3193
  this.originalImage.setCoords();
2802
3194
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2803
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2804
- return this._exportCanvasRegionToDataURL({
3195
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
3196
+ return await this._exportCanvasRegionToDataURL({
2805
3197
  ...exportRegion,
2806
3198
  multiplier,
2807
3199
  quality,
2808
- format
3200
+ format,
3201
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2809
3202
  });
2810
3203
  } finally {
2811
3204
  maskVisibilityBackups.forEach(backup => {
2812
3205
  try { backup.object.set({ visible: backup.visible }); } catch (error) { void error; }
2813
3206
  });
3207
+ this._restoreMaskExportBackups(maskStyleBackups);
3208
+ this._restoreMaskLabelBackups(labelBackups);
3209
+ this._restoreActiveObjectBackup(activeObjectBackup);
2814
3210
  this.canvas.renderAll();
2815
3211
  }
2816
3212
  }
2817
3213
 
2818
3214
  // Render masks as export shapes without mutating their editable styles.
2819
3215
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2820
- const maskStyleBackups = masks.map(mask => ({
2821
- object: mask,
2822
- opacity: mask.opacity,
2823
- fill: mask.fill,
2824
- strokeWidth: mask.strokeWidth,
2825
- stroke: mask.stroke,
2826
- selectable: mask.selectable,
2827
- lockRotation: mask.lockRotation
2828
- }));
3216
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
3217
+ const labelBackups = this._captureMaskLabelBackups(masks);
3218
+ const activeObjectBackup = this._captureActiveObjectBackup();
2829
3219
 
2830
3220
  let finalBase64;
2831
3221
  try {
@@ -2844,30 +3234,20 @@ function ensureFabric() {
2844
3234
  // Compute an integer canvas region for the base image.
2845
3235
  this.originalImage.setCoords();
2846
3236
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2847
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
3237
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2848
3238
 
2849
3239
  // Crop precisely in offscreen canvas
2850
- finalBase64 = this._exportCanvasRegionToDataURL({
3240
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2851
3241
  ...exportRegion,
2852
3242
  multiplier,
2853
3243
  quality,
2854
- format
3244
+ format,
3245
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2855
3246
  });
2856
3247
  } finally {
2857
- maskStyleBackups.forEach(backup => {
2858
- try {
2859
- backup.object.set({
2860
- opacity: backup.opacity,
2861
- fill: backup.fill,
2862
- strokeWidth: backup.strokeWidth,
2863
- stroke: backup.stroke,
2864
- selectable: backup.selectable,
2865
- lockRotation: backup.lockRotation
2866
- });
2867
- backup.object.setCoords();
2868
- } catch (error) { void error; }
2869
- });
2870
-
3248
+ this._restoreMaskExportBackups(maskStyleBackups);
3249
+ this._restoreMaskLabelBackups(labelBackups);
3250
+ this._restoreActiveObjectBackup(activeObjectBackup);
2871
3251
  this.canvas.renderAll();
2872
3252
  }
2873
3253
 
@@ -2915,6 +3295,7 @@ function ensureFabric() {
2915
3295
  } = options;
2916
3296
 
2917
3297
  const safeFileType = this._normalizeImageFormat(fileType);
3298
+ const normalizedQuality = this._normalizeQuality(quality);
2918
3299
 
2919
3300
  // Generate the data URL in the requested export mode.
2920
3301
  let imageBase64;
@@ -2922,14 +3303,14 @@ function ensureFabric() {
2922
3303
  imageBase64 = await this.exportImageBase64({
2923
3304
  exportImageArea: true,
2924
3305
  multiplier,
2925
- quality,
3306
+ quality: normalizedQuality,
2926
3307
  fileType: safeFileType
2927
3308
  });
2928
3309
  } else {
2929
3310
  imageBase64 = await this.exportImageBase64({
2930
3311
  exportImageArea: false,
2931
3312
  multiplier,
2932
- quality,
3313
+ quality: normalizedQuality,
2933
3314
  fileType: safeFileType
2934
3315
  });
2935
3316
  }
@@ -2949,7 +3330,7 @@ function ensureFabric() {
2949
3330
  const context = offscreenCanvas.getContext('2d');
2950
3331
  if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
2951
3332
  context.drawImage(imageElement, 0, 0);
2952
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
3333
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2953
3334
  resolve(convertedDataUrl);
2954
3335
  } catch (error) { reject(error); }
2955
3336
  };
@@ -2959,13 +3340,8 @@ function ensureFabric() {
2959
3340
  }
2960
3341
 
2961
3342
  // Convert the final data URL to a File with the requested MIME type.
2962
- const binaryString = atob(imageDataUrl.split(',')[1]);
3343
+ const bytes = this._decodeBase64Payload(imageDataUrl.split(',')[1]);
2963
3344
  const mime = `image/${safeFileType}`;
2964
- let byteIndex = binaryString.length;
2965
- const bytes = new Uint8Array(byteIndex);
2966
- while (byteIndex--) {
2967
- bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
2968
- }
2969
3345
  return new File([bytes], fileName, { type: mime });
2970
3346
  }
2971
3347
 
@@ -3126,6 +3502,30 @@ function ensureFabric() {
3126
3502
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
3127
3503
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
3128
3504
  cropRect.setCoords();
3505
+ const cropBounds = cropRect.getBoundingRect(true, true);
3506
+ const imageLeft = Number(imageBounds.left) || 0;
3507
+ const imageTop = Number(imageBounds.top) || 0;
3508
+ const imageRight = imageLeft + (Number(imageBounds.width) || 0);
3509
+ const imageBottom = imageTop + (Number(imageBounds.height) || 0);
3510
+ let deltaX = 0;
3511
+ let deltaY = 0;
3512
+ if (cropBounds.left < imageLeft) {
3513
+ deltaX = imageLeft - cropBounds.left;
3514
+ } else if (cropBounds.left + cropBounds.width > imageRight) {
3515
+ deltaX = imageRight - (cropBounds.left + cropBounds.width);
3516
+ }
3517
+ if (cropBounds.top < imageTop) {
3518
+ deltaY = imageTop - cropBounds.top;
3519
+ } else if (cropBounds.top + cropBounds.height > imageBottom) {
3520
+ deltaY = imageBottom - (cropBounds.top + cropBounds.height);
3521
+ }
3522
+ if (deltaX || deltaY) {
3523
+ cropRect.set({
3524
+ left: (Number(cropRect.left) || 0) + deltaX,
3525
+ top: (Number(cropRect.top) || 0) + deltaY
3526
+ });
3527
+ cropRect.setCoords();
3528
+ }
3129
3529
  this.canvas.requestRenderAll();
3130
3530
  } catch (error) { void error; }
3131
3531
  };
@@ -3205,23 +3605,19 @@ function ensureFabric() {
3205
3605
  const masks = this.canvas.getObjects().filter(object => object.maskId);
3206
3606
  if (masks && masks.length) {
3207
3607
  masks.forEach(mask => {
3208
- try {
3209
- mask.setCoords();
3210
- const maskBounds = mask.getBoundingRect(true, true);
3211
- const intersectsCrop =
3212
- maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
3213
- maskBounds.left + maskBounds.width > cropRegion.sourceX &&
3214
- maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3215
- maskBounds.top + maskBounds.height > cropRegion.sourceY;
3216
- this._removeLabelForMask(mask);
3217
- this.canvas.remove(mask);
3218
- if (shouldPreserveMasks && intersectsCrop) {
3219
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3220
- mask.set({ visible: true });
3221
- preservedMasks.push(mask);
3222
- }
3223
- } catch (error) {
3224
- this._reportWarning('applyCrop: failed to remove mask', error);
3608
+ mask.setCoords();
3609
+ const maskBounds = mask.getBoundingRect(true, true);
3610
+ const intersectsCrop =
3611
+ maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
3612
+ maskBounds.left + maskBounds.width > cropRegion.sourceX &&
3613
+ maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
3614
+ maskBounds.top + maskBounds.height > cropRegion.sourceY;
3615
+ this._removeLabelForMask(mask);
3616
+ this.canvas.remove(mask);
3617
+ if (shouldPreserveMasks && intersectsCrop) {
3618
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3619
+ mask.set({ visible: true });
3620
+ preservedMasks.push(mask);
3225
3621
  }
3226
3622
  });
3227
3623
  this._clearMaskPlacementMemory();
@@ -3229,7 +3625,8 @@ function ensureFabric() {
3229
3625
  this.canvas.renderAll();
3230
3626
  }
3231
3627
  } catch (error) {
3232
- this._reportWarning('applyCrop: error while removing masks', error);
3628
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
3629
+ return;
3233
3630
  }
3234
3631
 
3235
3632
  this._removeCropRect();
@@ -3321,6 +3718,7 @@ function ensureFabric() {
3321
3718
  const canUndo = this.historyManager?.canUndo();
3322
3719
  const canRedo = this.historyManager?.canRedo();
3323
3720
  const isInCropMode = !!this._cropMode;
3721
+ const isBusy = this.isBusy();
3324
3722
 
3325
3723
  if (isInCropMode) {
3326
3724
  // Disable all controls except the crop action buttons while crop mode is active.
@@ -3336,23 +3734,27 @@ function ensureFabric() {
3336
3734
  return;
3337
3735
  }
3338
3736
 
3339
- this._setDisabled('zoomInBtn', !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
3340
- this._setDisabled('zoomOutBtn', !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
3341
- this._setDisabled('rotateLeftBtn', !hasImage || this.isAnimating);
3342
- this._setDisabled('rotateRightBtn', !hasImage || this.isAnimating);
3343
- this._setDisabled('addMaskBtn', !hasImage || this.isAnimating);
3344
- this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
3345
- this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
3346
- this._setDisabled('mergeBtn', !hasImage || !hasMasks || this.isAnimating);
3347
- this._setDisabled('downloadBtn', !hasImage || this.isAnimating);
3348
- this._setDisabled('resetBtn', !hasImage || isDefaultTransform || this.isAnimating);
3349
- this._setDisabled('undoBtn', !hasImage || this.isAnimating || !canUndo);
3350
- this._setDisabled('redoBtn', !hasImage || this.isAnimating || !canRedo);
3351
- this._setDisabled('cropBtn', !hasImage || this.isAnimating);
3737
+ this._setDisabled('zoomInBtn', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3738
+ this._setDisabled('zoomOutBtn', !hasImage || isBusy || this.currentScale <= this.options.minScale);
3739
+ this._setDisabled('rotateLeftBtn', !hasImage || isBusy);
3740
+ this._setDisabled('rotateRightBtn', !hasImage || isBusy);
3741
+ this._setDisabled('addMaskBtn', !hasImage || isBusy);
3742
+ this._setDisabled('removeMaskBtn', !hasSelectedMask || isBusy);
3743
+ this._setDisabled('removeAllMasksBtn', !hasMasks || isBusy);
3744
+ this._setDisabled('mergeBtn', !hasImage || !hasMasks || isBusy);
3745
+ this._setDisabled('downloadBtn', !hasImage || isBusy);
3746
+ this._setDisabled('resetBtn', !hasImage || isDefaultTransform || isBusy);
3747
+ this._setDisabled('undoBtn', !hasImage || isBusy || !canUndo);
3748
+ this._setDisabled('redoBtn', !hasImage || isBusy || !canRedo);
3749
+ this._setDisabled('cropBtn', !hasImage || isBusy);
3352
3750
  this._setDisabled('applyCropBtn', true);
3353
3751
  this._setDisabled('cancelCropBtn', true);
3354
- this._setDisabled('imageInput', this.isAnimating);
3355
- this._setDisabled('uploadArea', this.isAnimating);
3752
+ this._setDisabled('scaleRate', !hasImage || isBusy);
3753
+ this._setDisabled('rotationLeftInput', !hasImage || isBusy);
3754
+ this._setDisabled('rotationRightInput', !hasImage || isBusy);
3755
+ this._setDisabled('maskList', !hasImage || isBusy);
3756
+ this._setDisabled('imageInput', isBusy);
3757
+ this._setDisabled('uploadArea', isBusy);
3356
3758
  }
3357
3759
 
3358
3760
  /**
@@ -3369,13 +3771,17 @@ function ensureFabric() {
3369
3771
  element.disabled = !!disabled;
3370
3772
  return;
3371
3773
  }
3774
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = new Map();
3775
+ if (!this._elementOriginalPointerEvents.has(key)) {
3776
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || '');
3777
+ }
3372
3778
 
3373
3779
  if (disabled) {
3374
3780
  element.setAttribute('aria-disabled', 'true');
3375
3781
  element.style.pointerEvents = 'none';
3376
3782
  } else {
3377
3783
  element.removeAttribute('aria-disabled');
3378
- element.style.pointerEvents = '';
3784
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? '';
3379
3785
  }
3380
3786
  }
3381
3787
 
@@ -3474,6 +3880,9 @@ function ensureFabric() {
3474
3880
  if (this.animationQueue) {
3475
3881
  this.animationQueue.cancelAll(new Error('Editor disposed'));
3476
3882
  }
3883
+ this._isLoading = false;
3884
+ this._activeOperationName = null;
3885
+ this._activeOperationToken = null;
3477
3886
 
3478
3887
  // Remove bound DOM event listeners
3479
3888
  try {
@@ -3520,17 +3929,20 @@ function ensureFabric() {
3520
3929
  }
3521
3930
  this._handlersByElementKey = {};
3522
3931
  this._elementCache = {};
3932
+ this._elementOriginalPointerEvents = new Map();
3523
3933
  this._clearMaskPlacementMemory();
3524
3934
  this.originalImage = null;
3525
3935
  this.baseImageScale = 1;
3526
3936
  this.currentScale = 1;
3527
3937
  this.currentRotation = 0;
3528
3938
  this.isAnimating = false;
3939
+ this._isLoading = false;
3529
3940
  this._cropMode = false;
3530
3941
  this._cropRect = null;
3531
3942
  this._cropHandlers = [];
3532
3943
  this._cropPrevEvented = null;
3533
3944
  this._prevSelectionSetting = undefined;
3945
+ this._lastContainerViewportSize = null;
3534
3946
  this._initialized = false;
3535
3947
  }
3536
3948
  }
@@ -3585,6 +3997,7 @@ function ensureFabric() {
3585
3997
  */
3586
3998
  this.isRunning = false;
3587
3999
  this.currentTask = null;
4000
+ this._generation = 0;
3588
4001
  }
3589
4002
 
3590
4003
  /**
@@ -3607,6 +4020,7 @@ function ensureFabric() {
3607
4020
  }
3608
4021
 
3609
4022
  cancelAll(reason = new Error('Animation queue cancelled')) {
4023
+ this._generation += 1;
3610
4024
  const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3611
4025
  const tasks = [
3612
4026
  ...(this.currentTask ? [this.currentTask] : []),
@@ -3629,29 +4043,35 @@ function ensureFabric() {
3629
4043
  */
3630
4044
  async _drainQueue() {
3631
4045
  if (this.isRunning) return;
4046
+ const generation = this._generation;
3632
4047
  this.isRunning = true;
3633
4048
 
3634
- while (this.animationTasks.length > 0) {
3635
- const task = this.animationTasks.shift();
3636
- this.currentTask = task;
4049
+ try {
4050
+ while (this.animationTasks.length > 0 && generation === this._generation) {
4051
+ const task = this.animationTasks.shift();
4052
+ this.currentTask = task;
3637
4053
 
3638
- try {
3639
- const result = await task.animationFn();
3640
- if (!task.isSettled) {
3641
- task.isSettled = true;
3642
- task.resolve(result);
3643
- }
3644
- } catch (error) {
3645
- if (!task.isSettled) {
3646
- task.isSettled = true;
3647
- task.reject(error);
4054
+ try {
4055
+ const result = await task.animationFn();
4056
+ if (generation === this._generation && !task.isSettled) {
4057
+ task.isSettled = true;
4058
+ task.resolve(result);
4059
+ }
4060
+ } catch (error) {
4061
+ if (generation === this._generation && !task.isSettled) {
4062
+ task.isSettled = true;
4063
+ task.reject(error);
4064
+ }
4065
+ } finally {
4066
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3648
4067
  }
3649
- } finally {
3650
- if (this.currentTask === task) this.currentTask = null;
4068
+ }
4069
+ } finally {
4070
+ if (generation === this._generation) {
4071
+ this.isRunning = false;
4072
+ this.currentTask = null;
3651
4073
  }
3652
4074
  }
3653
-
3654
- this.isRunning = false;
3655
4075
  }
3656
4076
  }
3657
4077
 
@@ -3722,9 +4142,9 @@ function ensureFabric() {
3722
4142
  execute(command) {
3723
4143
  const result = command.execute();
3724
4144
  if (result && typeof result.then === 'function') {
3725
- return Promise.resolve(result).then(() => {
4145
+ return this.enqueue(() => Promise.resolve(result).then(() => {
3726
4146
  this.push(command);
3727
- });
4147
+ }));
3728
4148
  }
3729
4149
  this.push(command);
3730
4150
  return result;