@bensitu/image-editor 1.4.0 → 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.4.0
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.
@@ -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
 
@@ -765,7 +783,7 @@ function ensureFabric() {
765
783
  imageElement,
766
784
  targetWidth,
767
785
  targetHeight,
768
- this.options.downsampleQuality,
786
+ this._normalizeQuality(this.options.downsampleQuality),
769
787
  imageBase64
770
788
  );
771
789
  }
@@ -844,6 +862,9 @@ function ensureFabric() {
844
862
  } catch (error) {
845
863
  await this._rollbackLoadImageTransaction(transaction);
846
864
  throw error;
865
+ } finally {
866
+ this._isLoading = false;
867
+ if (!this._disposed && this.canvas) this._updateUI();
847
868
  }
848
869
  }
849
870
 
@@ -961,9 +982,14 @@ function ensureFabric() {
961
982
 
962
983
  async _rollbackLoadImageTransaction(transaction) {
963
984
  if (!transaction || !this.canvas || this._disposed) return;
985
+ let didRestoreCanvasState = false;
964
986
  try {
965
- if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
987
+ if (transaction.canvasState) {
988
+ await this.loadFromState(transaction.canvasState);
989
+ didRestoreCanvasState = true;
990
+ }
966
991
  } catch (error) {
992
+ this._lastMask = null;
967
993
  this._reportError('loadImage rollback failed', error);
968
994
  }
969
995
 
@@ -973,16 +999,20 @@ function ensureFabric() {
973
999
  this.maskCounter = transaction.maskCounter;
974
1000
  this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
975
1001
  this._lastSnapshot = transaction.lastSnapshot;
1002
+ if (didRestoreCanvasState) {
1003
+ this._restoreLastMaskReference(transaction.lastMask);
1004
+ } else {
1005
+ this._lastMask = null;
1006
+ }
976
1007
  this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
977
1008
  this._lastMaskInitialTop = transaction.lastMaskInitialTop;
978
1009
  this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
979
- this._containerOriginalOverflow = transaction.containerOverflow;
980
1010
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
981
1011
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
982
1012
  if (this.containerElement) {
983
1013
  this.containerElement.scrollLeft = transaction.scrollLeft;
984
1014
  this.containerElement.scrollTop = transaction.scrollTop;
985
- this._restoreContainerOverflowState();
1015
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
986
1016
  }
987
1017
  this._updateInputs();
988
1018
  this._updateMaskList();
@@ -990,6 +1020,22 @@ function ensureFabric() {
990
1020
  if (this.canvas) this.canvas.renderAll();
991
1021
  }
992
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
+
993
1039
  /**
994
1040
  * Resamples the given image element to a new width and height and returns the result as a data URL.
995
1041
  *
@@ -1315,7 +1361,11 @@ function ensureFabric() {
1315
1361
  maskStyleBackups.push(backup);
1316
1362
  mask.set(stylePatch);
1317
1363
  });
1318
- 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;
1319
1369
  } finally {
1320
1370
  maskStyleBackups.forEach(backup => {
1321
1371
  try {
@@ -1387,9 +1437,15 @@ function ensureFabric() {
1387
1437
  * @returns {number} A finite quality value between 0 and 1.
1388
1438
  * @private
1389
1439
  */
1390
- _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;
1391
1447
  const numericQuality = Number(quality);
1392
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1448
+ if (!Number.isFinite(numericQuality)) return safeFallback;
1393
1449
  return Math.max(0, Math.min(1, numericQuality));
1394
1450
  }
1395
1451
 
@@ -1444,62 +1500,70 @@ function ensureFabric() {
1444
1500
  };
1445
1501
  }
1446
1502
 
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
- });
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');
1503
1567
  }
1504
1568
 
1505
1569
  /**
@@ -1513,13 +1577,16 @@ function ensureFabric() {
1513
1577
  * @param {number} [region.multiplier=1] - Export multiplier.
1514
1578
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1515
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.
1516
1581
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1517
1582
  * @private
1518
1583
  */
1519
- _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 }) {
1520
1585
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1521
- return this.canvas.toDataURL({
1522
- format,
1586
+ const safeFormat = this._normalizeImageFormat(format);
1587
+ const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
1588
+ let regionDataUrl = this.canvas.toDataURL({
1589
+ format: exportFormat,
1523
1590
  quality,
1524
1591
  multiplier: safeMultiplier,
1525
1592
  left: sourceX,
@@ -1527,6 +1594,32 @@ function ensureFabric() {
1527
1594
  width: sourceWidth,
1528
1595
  height: sourceHeight
1529
1596
  });
1597
+
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;
1530
1623
  }
1531
1624
 
1532
1625
  /**
@@ -1698,19 +1791,80 @@ function ensureFabric() {
1698
1791
  * @public
1699
1792
  */
1700
1793
  scaleImage(factor, options = {}) {
1701
- 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
+ }
1702
1828
  }
1703
1829
 
1704
- _assertIdleForOperation(operationName) {
1830
+ _withInternalOperationOptions(token, options = {}) {
1831
+ return {
1832
+ ...options,
1833
+ [INTERNAL_OPERATION_TOKEN]: token
1834
+ };
1835
+ }
1836
+
1837
+ _assertEditorAvailable(operationName) {
1705
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);
1706
1844
  if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
1707
1845
  throw new Error(`${operationName} cannot run while an animation is running`);
1708
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
+ }
1709
1863
  }
1710
1864
 
1711
- _canMutateNow(operationName) {
1865
+ _canMutateNow(operationName, options = {}) {
1712
1866
  try {
1713
- this._assertIdleForOperation(operationName);
1867
+ this._assertIdleForOperation(operationName, options);
1714
1868
  return true;
1715
1869
  } catch (error) {
1716
1870
  this._reportError(`${operationName} blocked`, error);
@@ -1824,7 +1978,15 @@ function ensureFabric() {
1824
1978
  * @public
1825
1979
  */
1826
1980
  rotateImage(degrees, options = {}) {
1827
- 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
+ });
1828
1990
  }
1829
1991
 
1830
1992
  /**
@@ -1894,6 +2056,11 @@ function ensureFabric() {
1894
2056
  */
1895
2057
  resetImageTransform() {
1896
2058
  if (!this.originalImage) return Promise.resolve();
2059
+ try {
2060
+ this._assertCanQueueAnimation('resetImageTransform');
2061
+ } catch (error) {
2062
+ return Promise.reject(error);
2063
+ }
1897
2064
 
1898
2065
  return this.animationQueue.add(async () => {
1899
2066
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
@@ -1901,6 +2068,8 @@ function ensureFabric() {
1901
2068
  await this._rotateImageImpl(0, { saveHistory: false });
1902
2069
  const after = this._captureCanvasStateOrThrow('resetImageTransform');
1903
2070
  this._pushStateTransition(before, after);
2071
+ }).finally(() => {
2072
+ if (!this._disposed && this.canvas) this._updateUI();
1904
2073
  }).catch(error => {
1905
2074
  this._reportError('resetImageTransform() failed', error);
1906
2075
  throw error;
@@ -2045,12 +2214,24 @@ function ensureFabric() {
2045
2214
  if (isSettled) return;
2046
2215
  isSettled = true;
2047
2216
  clearTimeout(timerId);
2048
- imageElement.onload = null;
2049
- imageElement.onerror = null;
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
+ }
2050
2224
  callback();
2051
2225
  };
2052
- imageElement.onload = () => settle(resolve);
2053
- imageElement.onerror = (error) => settle(() => reject(error));
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;
2234
+ }
2054
2235
  });
2055
2236
  }
2056
2237
 
@@ -2435,7 +2616,7 @@ function ensureFabric() {
2435
2616
  */
2436
2617
  removeAllMasks(options = {}) {
2437
2618
  if (!this.canvas) return;
2438
- if (!this._canMutateNow('removeAllMasks')) return;
2619
+ if (!this._canMutateNow('removeAllMasks', options)) return;
2439
2620
  const saveHistory = options.saveHistory !== false;
2440
2621
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2441
2622
  masks.forEach(mask => this._removeLabelForMask(mask));
@@ -2714,20 +2895,35 @@ function ensureFabric() {
2714
2895
  this._assertIdleForOperation('mergeMasks');
2715
2896
  const masks = this.canvas.getObjects().filter(object => object.maskId);
2716
2897
  if (!masks.length) return;
2898
+ const beforeJson = this._serializeCanvasState();
2899
+ const operationToken = this._beginBusyOperation('mergeMasks');
2717
2900
 
2718
2901
  this.canvas.discardActiveObject();
2719
2902
  this.canvas.renderAll();
2720
2903
 
2721
2904
  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 });
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
+ }));
2726
2915
  const afterJson = this._serializeCanvasState();
2727
2916
  this._pushStateTransition(beforeJson, afterJson);
2728
2917
  } catch (error) {
2729
2918
  this._reportError('merge error', error);
2919
+ try {
2920
+ await this.loadFromState(beforeJson);
2921
+ } catch (restoreError) {
2922
+ this._reportError('mergeMasks rollback failed', restoreError);
2923
+ }
2730
2924
  throw error;
2925
+ } finally {
2926
+ this._endBusyOperation(operationToken);
2731
2927
  }
2732
2928
  }
2733
2929
 
@@ -2783,7 +2979,7 @@ function ensureFabric() {
2783
2979
  */
2784
2980
  async exportImageBase64(options = {}) {
2785
2981
  if (!this.originalImage) throw new Error('No image loaded');
2786
- this._assertIdleForOperation('exportImageBase64');
2982
+ this._assertIdleForOperation('exportImageBase64', options);
2787
2983
  const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2788
2984
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2789
2985
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2800,12 +2996,13 @@ function ensureFabric() {
2800
2996
 
2801
2997
  this.originalImage.setCoords();
2802
2998
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2803
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2804
- return this._exportCanvasRegionToDataURL({
2999
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
3000
+ return await this._exportCanvasRegionToDataURL({
2805
3001
  ...exportRegion,
2806
3002
  multiplier,
2807
3003
  quality,
2808
- format
3004
+ format,
3005
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2809
3006
  });
2810
3007
  } finally {
2811
3008
  maskVisibilityBackups.forEach(backup => {
@@ -2844,14 +3041,15 @@ function ensureFabric() {
2844
3041
  // Compute an integer canvas region for the base image.
2845
3042
  this.originalImage.setCoords();
2846
3043
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2847
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
3044
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2848
3045
 
2849
3046
  // Crop precisely in offscreen canvas
2850
- finalBase64 = this._exportCanvasRegionToDataURL({
3047
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2851
3048
  ...exportRegion,
2852
3049
  multiplier,
2853
3050
  quality,
2854
- format
3051
+ format,
3052
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2855
3053
  });
2856
3054
  } finally {
2857
3055
  maskStyleBackups.forEach(backup => {
@@ -2915,6 +3113,7 @@ function ensureFabric() {
2915
3113
  } = options;
2916
3114
 
2917
3115
  const safeFileType = this._normalizeImageFormat(fileType);
3116
+ const normalizedQuality = this._normalizeQuality(quality);
2918
3117
 
2919
3118
  // Generate the data URL in the requested export mode.
2920
3119
  let imageBase64;
@@ -2922,14 +3121,14 @@ function ensureFabric() {
2922
3121
  imageBase64 = await this.exportImageBase64({
2923
3122
  exportImageArea: true,
2924
3123
  multiplier,
2925
- quality,
3124
+ quality: normalizedQuality,
2926
3125
  fileType: safeFileType
2927
3126
  });
2928
3127
  } else {
2929
3128
  imageBase64 = await this.exportImageBase64({
2930
3129
  exportImageArea: false,
2931
3130
  multiplier,
2932
- quality,
3131
+ quality: normalizedQuality,
2933
3132
  fileType: safeFileType
2934
3133
  });
2935
3134
  }
@@ -2949,7 +3148,7 @@ function ensureFabric() {
2949
3148
  const context = offscreenCanvas.getContext('2d');
2950
3149
  if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
2951
3150
  context.drawImage(imageElement, 0, 0);
2952
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
3151
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2953
3152
  resolve(convertedDataUrl);
2954
3153
  } catch (error) { reject(error); }
2955
3154
  };
@@ -3321,6 +3520,7 @@ function ensureFabric() {
3321
3520
  const canUndo = this.historyManager?.canUndo();
3322
3521
  const canRedo = this.historyManager?.canRedo();
3323
3522
  const isInCropMode = !!this._cropMode;
3523
+ const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
3324
3524
 
3325
3525
  if (isInCropMode) {
3326
3526
  // Disable all controls except the crop action buttons while crop mode is active.
@@ -3336,23 +3536,23 @@ function ensureFabric() {
3336
3536
  return;
3337
3537
  }
3338
3538
 
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);
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);
3352
3552
  this._setDisabled('applyCropBtn', true);
3353
3553
  this._setDisabled('cancelCropBtn', true);
3354
- this._setDisabled('imageInput', this.isAnimating);
3355
- this._setDisabled('uploadArea', this.isAnimating);
3554
+ this._setDisabled('imageInput', isBusy);
3555
+ this._setDisabled('uploadArea', isBusy);
3356
3556
  }
3357
3557
 
3358
3558
  /**
@@ -3369,13 +3569,17 @@ function ensureFabric() {
3369
3569
  element.disabled = !!disabled;
3370
3570
  return;
3371
3571
  }
3572
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = new Map();
3573
+ if (!this._elementOriginalPointerEvents.has(key)) {
3574
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || '');
3575
+ }
3372
3576
 
3373
3577
  if (disabled) {
3374
3578
  element.setAttribute('aria-disabled', 'true');
3375
3579
  element.style.pointerEvents = 'none';
3376
3580
  } else {
3377
3581
  element.removeAttribute('aria-disabled');
3378
- element.style.pointerEvents = '';
3582
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? '';
3379
3583
  }
3380
3584
  }
3381
3585
 
@@ -3474,6 +3678,9 @@ function ensureFabric() {
3474
3678
  if (this.animationQueue) {
3475
3679
  this.animationQueue.cancelAll(new Error('Editor disposed'));
3476
3680
  }
3681
+ this._isLoading = false;
3682
+ this._activeOperationName = null;
3683
+ this._activeOperationToken = null;
3477
3684
 
3478
3685
  // Remove bound DOM event listeners
3479
3686
  try {
@@ -3520,17 +3727,20 @@ function ensureFabric() {
3520
3727
  }
3521
3728
  this._handlersByElementKey = {};
3522
3729
  this._elementCache = {};
3730
+ this._elementOriginalPointerEvents = new Map();
3523
3731
  this._clearMaskPlacementMemory();
3524
3732
  this.originalImage = null;
3525
3733
  this.baseImageScale = 1;
3526
3734
  this.currentScale = 1;
3527
3735
  this.currentRotation = 0;
3528
3736
  this.isAnimating = false;
3737
+ this._isLoading = false;
3529
3738
  this._cropMode = false;
3530
3739
  this._cropRect = null;
3531
3740
  this._cropHandlers = [];
3532
3741
  this._cropPrevEvented = null;
3533
3742
  this._prevSelectionSetting = undefined;
3743
+ this._lastContainerViewportSize = null;
3534
3744
  this._initialized = false;
3535
3745
  }
3536
3746
  }
@@ -3585,6 +3795,7 @@ function ensureFabric() {
3585
3795
  */
3586
3796
  this.isRunning = false;
3587
3797
  this.currentTask = null;
3798
+ this._generation = 0;
3588
3799
  }
3589
3800
 
3590
3801
  /**
@@ -3607,6 +3818,7 @@ function ensureFabric() {
3607
3818
  }
3608
3819
 
3609
3820
  cancelAll(reason = new Error('Animation queue cancelled')) {
3821
+ this._generation += 1;
3610
3822
  const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3611
3823
  const tasks = [
3612
3824
  ...(this.currentTask ? [this.currentTask] : []),
@@ -3629,29 +3841,35 @@ function ensureFabric() {
3629
3841
  */
3630
3842
  async _drainQueue() {
3631
3843
  if (this.isRunning) return;
3844
+ const generation = this._generation;
3632
3845
  this.isRunning = true;
3633
3846
 
3634
- while (this.animationTasks.length > 0) {
3635
- const task = this.animationTasks.shift();
3636
- this.currentTask = task;
3847
+ try {
3848
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3849
+ const task = this.animationTasks.shift();
3850
+ this.currentTask = task;
3637
3851
 
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);
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;
3648
3865
  }
3649
- } finally {
3650
- if (this.currentTask === task) this.currentTask = null;
3866
+ }
3867
+ } finally {
3868
+ if (generation === this._generation) {
3869
+ this.isRunning = false;
3870
+ this.currentTask = null;
3651
3871
  }
3652
3872
  }
3653
-
3654
- this.isRunning = false;
3655
3873
  }
3656
3874
  }
3657
3875