@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.
@@ -5,12 +5,13 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.4.0
8
+ * @version 1.4.1
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
12
12
  */
13
13
  var fabric = null;
14
+ var INTERNAL_OPERATION_TOKEN = /* @__PURE__ */ Symbol("ImageEditorInternalOperation");
14
15
  function getGlobalScope() {
15
16
  if (typeof globalThis !== "undefined") return globalThis;
16
17
  if (typeof self !== "undefined") return self;
@@ -118,11 +119,15 @@ var ImageEditor = class {
118
119
  this.currentRotation = 0;
119
120
  this.maskCounter = 0;
120
121
  this.isAnimating = false;
122
+ this._isLoading = false;
123
+ this._activeOperationName = null;
124
+ this._activeOperationToken = null;
121
125
  this.elements = {};
122
126
  this.isImageLoadedToCanvas = false;
123
127
  this.maxHistorySize = 50;
124
128
  this._handlersByElementKey = {};
125
129
  this._elementCache = {};
130
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
126
131
  this._lastMask = null;
127
132
  this._lastMaskInitialLeft = null;
128
133
  this._lastMaskInitialTop = null;
@@ -211,6 +216,10 @@ var ImageEditor = class {
211
216
  this.historyManager = new HistoryManager(this.maxHistorySize);
212
217
  this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
213
218
  this._activeAnimationRejectors = /* @__PURE__ */ new Set();
219
+ this._isLoading = false;
220
+ this._activeOperationName = null;
221
+ this._activeOperationToken = null;
222
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
214
223
  this._containerOriginalOverflow = null;
215
224
  this._lastContainerViewportSize = null;
216
225
  this._canvasElementOriginalStyle = null;
@@ -417,6 +426,12 @@ var ImageEditor = class {
417
426
  this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
418
427
  this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
419
428
  }
429
+ _restoreContainerOverflowSnapshot(snapshot) {
430
+ if (!this.containerElement || !this.containerElement.style || !snapshot) return;
431
+ this.containerElement.style.overflow = snapshot.overflow || "";
432
+ this.containerElement.style.overflowX = snapshot.overflowX || "";
433
+ this.containerElement.style.overflowY = snapshot.overflowY || "";
434
+ }
420
435
  /**
421
436
  * DOM / UI bindings
422
437
  * @private
@@ -551,7 +566,9 @@ var ImageEditor = class {
551
566
  if (!this._fabricLoaded) return;
552
567
  if (!this.canvas || this._disposed) return;
553
568
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
554
- this._assertIdleForOperation("loadImage");
569
+ this._assertIdleForOperation("loadImage", options);
570
+ this._isLoading = true;
571
+ this._updateUI();
555
572
  this._warnOnImageLayoutOptionConflict();
556
573
  const transaction = this._captureLoadImageTransaction();
557
574
  try {
@@ -571,7 +588,7 @@ var ImageEditor = class {
571
588
  imageElement,
572
589
  targetWidth,
573
590
  targetHeight,
574
- this.options.downsampleQuality,
591
+ this._normalizeQuality(this.options.downsampleQuality),
575
592
  imageBase64
576
593
  );
577
594
  }
@@ -639,6 +656,9 @@ var ImageEditor = class {
639
656
  } catch (error) {
640
657
  await this._rollbackLoadImageTransaction(transaction);
641
658
  throw error;
659
+ } finally {
660
+ this._isLoading = false;
661
+ if (!this._disposed && this.canvas) this._updateUI();
642
662
  }
643
663
  }
644
664
  /**
@@ -744,9 +764,14 @@ var ImageEditor = class {
744
764
  }
745
765
  async _rollbackLoadImageTransaction(transaction) {
746
766
  if (!transaction || !this.canvas || this._disposed) return;
767
+ let didRestoreCanvasState = false;
747
768
  try {
748
- if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
769
+ if (transaction.canvasState) {
770
+ await this.loadFromState(transaction.canvasState);
771
+ didRestoreCanvasState = true;
772
+ }
749
773
  } catch (error) {
774
+ this._lastMask = null;
750
775
  this._reportError("loadImage rollback failed", error);
751
776
  }
752
777
  this.baseImageScale = transaction.baseImageScale;
@@ -755,22 +780,40 @@ var ImageEditor = class {
755
780
  this.maskCounter = transaction.maskCounter;
756
781
  this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
757
782
  this._lastSnapshot = transaction.lastSnapshot;
783
+ if (didRestoreCanvasState) {
784
+ this._restoreLastMaskReference(transaction.lastMask);
785
+ } else {
786
+ this._lastMask = null;
787
+ }
758
788
  this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
759
789
  this._lastMaskInitialTop = transaction.lastMaskInitialTop;
760
790
  this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
761
- this._containerOriginalOverflow = transaction.containerOverflow;
762
791
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
763
792
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
764
793
  if (this.containerElement) {
765
794
  this.containerElement.scrollLeft = transaction.scrollLeft;
766
795
  this.containerElement.scrollTop = transaction.scrollTop;
767
- this._restoreContainerOverflowState();
796
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
768
797
  }
769
798
  this._updateInputs();
770
799
  this._updateMaskList();
771
800
  this._updateUI();
772
801
  if (this.canvas) this.canvas.renderAll();
773
802
  }
803
+ _restoreLastMaskReference(previousLastMask) {
804
+ if (!this.canvas) {
805
+ this._lastMask = null;
806
+ return;
807
+ }
808
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
809
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
810
+ this._lastMask = masks.find((mask) => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
811
+ if (!this._lastMask) {
812
+ this._lastMaskInitialLeft = null;
813
+ this._lastMaskInitialTop = null;
814
+ this._lastMaskInitialWidth = null;
815
+ }
816
+ }
774
817
  /**
775
818
  * Resamples the given image element to a new width and height and returns the result as a data URL.
776
819
  *
@@ -1054,7 +1097,11 @@ var ImageEditor = class {
1054
1097
  maskStyleBackups.push(backup);
1055
1098
  mask.set(stylePatch);
1056
1099
  });
1057
- return callback();
1100
+ const result = callback();
1101
+ if (result && typeof result.then === "function") {
1102
+ throw new Error("_withNormalizedMaskStyles callback must be synchronous");
1103
+ }
1104
+ return result;
1058
1105
  } finally {
1059
1106
  maskStyleBackups.forEach((backup) => {
1060
1107
  try {
@@ -1122,9 +1169,13 @@ var ImageEditor = class {
1122
1169
  * @returns {number} A finite quality value between 0 and 1.
1123
1170
  * @private
1124
1171
  */
1125
- _normalizeQuality(quality) {
1172
+ _normalizeQuality(quality, fallback = void 0) {
1173
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1174
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1175
+ const safeFallback = Number.isFinite(numericFallback) ? Math.max(0, Math.min(1, numericFallback)) : 0.92;
1176
+ if (quality == null) return safeFallback;
1126
1177
  const numericQuality = Number(quality);
1127
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1178
+ if (!Number.isFinite(numericQuality)) return safeFallback;
1128
1179
  return Math.max(0, Math.min(1, numericQuality));
1129
1180
  }
1130
1181
  /**
@@ -1175,64 +1226,63 @@ var ImageEditor = class {
1175
1226
  sourceHeight: Math.max(1, endY - sourceY)
1176
1227
  };
1177
1228
  }
1178
- /**
1179
- * Crops an image data URL to a source region using an offscreen canvas.
1180
- *
1181
- * @param {string} dataUrl - Source image data URL.
1182
- * @param {number} sourceX - Source region x coordinate.
1183
- * @param {number} sourceY - Source region y coordinate.
1184
- * @param {number} sourceWidth - Source region width.
1185
- * @param {number} sourceHeight - Source region height.
1186
- * @param {number} multiplier - Export multiplier already applied to the source data URL.
1187
- * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1188
- * @param {number} [quality=0.92] - Output image quality for lossy formats.
1189
- * @returns {Promise<string>} Resolves with the cropped image data URL.
1190
- * @private
1191
- */
1192
- async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
1193
- return new Promise((resolve, reject) => {
1194
- const imageElement = new Image();
1195
- let isSettled = false;
1196
- const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1197
- const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1198
- let timerId;
1199
- const settle = (callback) => {
1200
- if (isSettled) return;
1201
- isSettled = true;
1202
- clearTimeout(timerId);
1203
- imageElement.onload = null;
1204
- imageElement.onerror = null;
1205
- callback();
1206
- };
1207
- timerId = setTimeout(() => {
1208
- settle(() => reject(new Error("Image crop load timed out")));
1209
- try {
1210
- imageElement.src = "";
1211
- } catch (error) {
1212
- void error;
1213
- }
1214
- }, safeTimeoutMs);
1215
- imageElement.onload = () => {
1216
- try {
1217
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1218
- const scaledSourceX = Math.round(sourceX * safeMultiplier);
1219
- const scaledSourceY = Math.round(sourceY * safeMultiplier);
1220
- const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1221
- const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1222
- const offscreenCanvas = document.createElement("canvas");
1223
- offscreenCanvas.width = scaledSourceWidth;
1224
- offscreenCanvas.height = scaledSourceHeight;
1225
- const context = offscreenCanvas.getContext("2d");
1226
- if (!context) throw new Error("2D canvas context is unavailable");
1227
- context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1228
- settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1229
- } catch (error) {
1230
- settle(() => reject(error));
1231
- }
1232
- };
1233
- imageElement.onerror = (error) => settle(() => reject(error));
1234
- imageElement.src = dataUrl;
1235
- });
1229
+ _hasFractionalCanvasEdge(value) {
1230
+ const numericValue = Number(value);
1231
+ if (!Number.isFinite(numericValue)) return false;
1232
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1233
+ }
1234
+ _getPartialExportEdges(bounds) {
1235
+ if (!bounds) return null;
1236
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1237
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1238
+ if (!isAxisAligned) return null;
1239
+ return {
1240
+ left: this._hasFractionalCanvasEdge(bounds.left),
1241
+ top: this._hasFractionalCanvasEdge(bounds.top),
1242
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1243
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1244
+ };
1245
+ }
1246
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1247
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1248
+ const imageElement = await this._createImageElement(dataUrl);
1249
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1250
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1251
+ const offscreenCanvas = document.createElement("canvas");
1252
+ offscreenCanvas.width = width;
1253
+ offscreenCanvas.height = height;
1254
+ const context = offscreenCanvas.getContext("2d");
1255
+ if (!context) throw new Error("2D canvas context is unavailable");
1256
+ context.drawImage(imageElement, 0, 0, width, height);
1257
+ const imageData = context.getImageData(0, 0, width, height);
1258
+ const pixels = imageData.data;
1259
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1260
+ const index = (y * width + x) * 4;
1261
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1262
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1263
+ pixels[index] = pixels[fallbackIndex];
1264
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1265
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1266
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1267
+ }
1268
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1269
+ pixels[index + 3] = 255;
1270
+ }
1271
+ };
1272
+ if (edges.left && width > 1) {
1273
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1274
+ }
1275
+ if (edges.right && width > 1) {
1276
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1277
+ }
1278
+ if (edges.top && height > 1) {
1279
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1280
+ }
1281
+ if (edges.bottom && height > 1) {
1282
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1283
+ }
1284
+ context.putImageData(imageData, 0, 0);
1285
+ return offscreenCanvas.toDataURL("image/png");
1236
1286
  }
1237
1287
  /**
1238
1288
  * Exports a source region directly through Fabric's region export options.
@@ -1245,13 +1295,16 @@ var ImageEditor = class {
1245
1295
  * @param {number} [region.multiplier=1] - Export multiplier.
1246
1296
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1247
1297
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1298
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1248
1299
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1249
1300
  * @private
1250
1301
  */
1251
- _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1302
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1252
1303
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1253
- return this.canvas.toDataURL({
1254
- format,
1304
+ const safeFormat = this._normalizeImageFormat(format);
1305
+ const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1306
+ let regionDataUrl = this.canvas.toDataURL({
1307
+ format: exportFormat,
1255
1308
  quality,
1256
1309
  multiplier: safeMultiplier,
1257
1310
  left: sourceX,
@@ -1259,6 +1312,29 @@ var ImageEditor = class {
1259
1312
  width: sourceWidth,
1260
1313
  height: sourceHeight
1261
1314
  });
1315
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1316
+ if (safeFormat !== "jpeg") return regionDataUrl;
1317
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1318
+ }
1319
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1320
+ const imageElement = await this._createImageElement(dataUrl);
1321
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1322
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1323
+ const offscreenCanvas = document.createElement("canvas");
1324
+ offscreenCanvas.width = width;
1325
+ offscreenCanvas.height = height;
1326
+ const context = offscreenCanvas.getContext("2d");
1327
+ if (!context) throw new Error("2D canvas context is unavailable");
1328
+ context.fillStyle = this._getJpegBackgroundColor();
1329
+ context.fillRect(0, 0, width, height);
1330
+ context.drawImage(imageElement, 0, 0, width, height);
1331
+ return offscreenCanvas.toDataURL("image/jpeg", this._normalizeQuality(quality));
1332
+ }
1333
+ _getJpegBackgroundColor() {
1334
+ const backgroundColor = String(this.options.backgroundColor || "").trim();
1335
+ if (!backgroundColor || backgroundColor === "transparent") return "#ffffff";
1336
+ if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return "#ffffff";
1337
+ return backgroundColor;
1262
1338
  }
1263
1339
  /**
1264
1340
  * Gets the top-left corner coordinates of the given object.
@@ -1416,17 +1492,70 @@ var ImageEditor = class {
1416
1492
  * @public
1417
1493
  */
1418
1494
  scaleImage(factor, options = {}) {
1419
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1495
+ try {
1496
+ this._assertCanQueueAnimation("scaleImage", options);
1497
+ } catch (error) {
1498
+ return Promise.reject(error);
1499
+ }
1500
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1501
+ if (!this._disposed && this.canvas) this._updateUI();
1502
+ });
1503
+ }
1504
+ _getInternalOperationToken(options) {
1505
+ return options && options[INTERNAL_OPERATION_TOKEN];
1420
1506
  }
1421
- _assertIdleForOperation(operationName) {
1507
+ _isOwnInternalOperation(options) {
1508
+ const token = this._getInternalOperationToken(options);
1509
+ return !!token && token === this._activeOperationToken;
1510
+ }
1511
+ _beginBusyOperation(operationName) {
1512
+ const token = Symbol(operationName);
1513
+ this._activeOperationName = operationName;
1514
+ this._activeOperationToken = token;
1515
+ this._updateUI();
1516
+ return token;
1517
+ }
1518
+ _endBusyOperation(token) {
1519
+ if (token && token === this._activeOperationToken) {
1520
+ this._activeOperationName = null;
1521
+ this._activeOperationToken = null;
1522
+ this._updateUI();
1523
+ }
1524
+ }
1525
+ _withInternalOperationOptions(token, options = {}) {
1526
+ return {
1527
+ ...options,
1528
+ [INTERNAL_OPERATION_TOKEN]: token
1529
+ };
1530
+ }
1531
+ _assertEditorAvailable(operationName) {
1422
1532
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1533
+ }
1534
+ _assertIdleForOperation(operationName, options = {}) {
1535
+ this._assertEditorAvailable(operationName);
1536
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1423
1537
  if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1424
1538
  throw new Error(`${operationName} cannot run while an animation is running`);
1425
1539
  }
1540
+ if (this._isLoading && !isOwnInternalOperation) {
1541
+ throw new Error(`${operationName} cannot run while an image is loading`);
1542
+ }
1543
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1544
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1545
+ }
1546
+ }
1547
+ _assertCanQueueAnimation(operationName, options = {}) {
1548
+ this._assertEditorAvailable(operationName);
1549
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1550
+ throw new Error(`${operationName} cannot run while an image is loading`);
1551
+ }
1552
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1553
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1554
+ }
1426
1555
  }
1427
- _canMutateNow(operationName) {
1556
+ _canMutateNow(operationName, options = {}) {
1428
1557
  try {
1429
- this._assertIdleForOperation(operationName);
1558
+ this._assertIdleForOperation(operationName, options);
1430
1559
  return true;
1431
1560
  } catch (error) {
1432
1561
  this._reportError(`${operationName} blocked`, error);
@@ -1531,7 +1660,14 @@ var ImageEditor = class {
1531
1660
  * @public
1532
1661
  */
1533
1662
  rotateImage(degrees, options = {}) {
1534
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1663
+ try {
1664
+ this._assertCanQueueAnimation("rotateImage", options);
1665
+ } catch (error) {
1666
+ return Promise.reject(error);
1667
+ }
1668
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
1669
+ if (!this._disposed && this.canvas) this._updateUI();
1670
+ });
1535
1671
  }
1536
1672
  /**
1537
1673
  * Rotates the original image by a given number of degrees, with animation.
@@ -1593,12 +1729,19 @@ var ImageEditor = class {
1593
1729
  */
1594
1730
  resetImageTransform() {
1595
1731
  if (!this.originalImage) return Promise.resolve();
1732
+ try {
1733
+ this._assertCanQueueAnimation("resetImageTransform");
1734
+ } catch (error) {
1735
+ return Promise.reject(error);
1736
+ }
1596
1737
  return this.animationQueue.add(async () => {
1597
1738
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1598
1739
  await this._scaleImageImpl(1, { saveHistory: false });
1599
1740
  await this._rotateImageImpl(0, { saveHistory: false });
1600
1741
  const after = this._captureCanvasStateOrThrow("resetImageTransform");
1601
1742
  this._pushStateTransition(before, after);
1743
+ }).finally(() => {
1744
+ if (!this._disposed && this.canvas) this._updateUI();
1602
1745
  }).catch((error) => {
1603
1746
  this._reportError("resetImageTransform() failed", error);
1604
1747
  throw error;
@@ -1723,12 +1866,24 @@ var ImageEditor = class {
1723
1866
  if (isSettled) return;
1724
1867
  isSettled = true;
1725
1868
  clearTimeout(timerId);
1726
- imageElement.onload = null;
1727
- imageElement.onerror = null;
1869
+ if (typeof imageElement.removeEventListener === "function") {
1870
+ imageElement.removeEventListener("load", handleLoad);
1871
+ imageElement.removeEventListener("error", handleError);
1872
+ } else {
1873
+ imageElement.onload = null;
1874
+ imageElement.onerror = null;
1875
+ }
1728
1876
  callback();
1729
1877
  };
1730
- imageElement.onload = () => settle(resolve);
1731
- imageElement.onerror = (error) => settle(() => reject(error));
1878
+ const handleLoad = () => settle(resolve);
1879
+ const handleError = (error) => settle(() => reject(error));
1880
+ if (typeof imageElement.addEventListener === "function") {
1881
+ imageElement.addEventListener("load", handleLoad, { once: true });
1882
+ imageElement.addEventListener("error", handleError, { once: true });
1883
+ } else {
1884
+ imageElement.onload = handleLoad;
1885
+ imageElement.onerror = handleError;
1886
+ }
1732
1887
  });
1733
1888
  }
1734
1889
  /**
@@ -2081,7 +2236,7 @@ var ImageEditor = class {
2081
2236
  */
2082
2237
  removeAllMasks(options = {}) {
2083
2238
  if (!this.canvas) return;
2084
- if (!this._canMutateNow("removeAllMasks")) return;
2239
+ if (!this._canMutateNow("removeAllMasks", options)) return;
2085
2240
  const saveHistory = options.saveHistory !== false;
2086
2241
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2087
2242
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -2354,18 +2509,33 @@ var ImageEditor = class {
2354
2509
  this._assertIdleForOperation("mergeMasks");
2355
2510
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2356
2511
  if (!masks.length) return;
2512
+ const beforeJson = this._serializeCanvasState();
2513
+ const operationToken = this._beginBusyOperation("mergeMasks");
2357
2514
  this.canvas.discardActiveObject();
2358
2515
  this.canvas.renderAll();
2359
2516
  try {
2360
- const beforeJson = this._serializeCanvasState();
2361
- const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2362
- this.removeAllMasks({ saveHistory: false });
2363
- await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2517
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
2518
+ exportImageArea: true,
2519
+ multiplier: this.options.exportMultiplier,
2520
+ fileType: "png"
2521
+ }));
2522
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2523
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2524
+ preserveScroll: true,
2525
+ resetMaskCounter: false
2526
+ }));
2364
2527
  const afterJson = this._serializeCanvasState();
2365
2528
  this._pushStateTransition(beforeJson, afterJson);
2366
2529
  } catch (error) {
2367
2530
  this._reportError("merge error", error);
2531
+ try {
2532
+ await this.loadFromState(beforeJson);
2533
+ } catch (restoreError) {
2534
+ this._reportError("mergeMasks rollback failed", restoreError);
2535
+ }
2368
2536
  throw error;
2537
+ } finally {
2538
+ this._endBusyOperation(operationToken);
2369
2539
  }
2370
2540
  }
2371
2541
  /**
@@ -2416,7 +2586,7 @@ var ImageEditor = class {
2416
2586
  */
2417
2587
  async exportImageBase64(options = {}) {
2418
2588
  if (!this.originalImage) throw new Error("No image loaded");
2419
- this._assertIdleForOperation("exportImageBase64");
2589
+ this._assertIdleForOperation("exportImageBase64", options);
2420
2590
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2421
2591
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2422
2592
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2432,12 +2602,13 @@ var ImageEditor = class {
2432
2602
  this.canvas.renderAll();
2433
2603
  this.originalImage.setCoords();
2434
2604
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2435
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2436
- return this._exportCanvasRegionToDataURL({
2605
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2606
+ return await this._exportCanvasRegionToDataURL({
2437
2607
  ...exportRegion,
2438
2608
  multiplier,
2439
2609
  quality,
2440
- format
2610
+ format,
2611
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2441
2612
  });
2442
2613
  } finally {
2443
2614
  maskVisibilityBackups.forEach((backup) => {
@@ -2472,12 +2643,13 @@ var ImageEditor = class {
2472
2643
  this.canvas.renderAll();
2473
2644
  this.originalImage.setCoords();
2474
2645
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2475
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2476
- finalBase64 = this._exportCanvasRegionToDataURL({
2646
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2647
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2477
2648
  ...exportRegion,
2478
2649
  multiplier,
2479
2650
  quality,
2480
- format
2651
+ format,
2652
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2481
2653
  });
2482
2654
  } finally {
2483
2655
  maskStyleBackups.forEach((backup) => {
@@ -2538,19 +2710,20 @@ var ImageEditor = class {
2538
2710
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
2539
2711
  } = options;
2540
2712
  const safeFileType = this._normalizeImageFormat(fileType);
2713
+ const normalizedQuality = this._normalizeQuality(quality);
2541
2714
  let imageBase64;
2542
2715
  if (mergeMask) {
2543
2716
  imageBase64 = await this.exportImageBase64({
2544
2717
  exportImageArea: true,
2545
2718
  multiplier,
2546
- quality,
2719
+ quality: normalizedQuality,
2547
2720
  fileType: safeFileType
2548
2721
  });
2549
2722
  } else {
2550
2723
  imageBase64 = await this.exportImageBase64({
2551
2724
  exportImageArea: false,
2552
2725
  multiplier,
2553
- quality,
2726
+ quality: normalizedQuality,
2554
2727
  fileType: safeFileType
2555
2728
  });
2556
2729
  }
@@ -2567,7 +2740,7 @@ var ImageEditor = class {
2567
2740
  const context = offscreenCanvas.getContext("2d");
2568
2741
  if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2569
2742
  context.drawImage(imageElement, 0, 0);
2570
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2743
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2571
2744
  resolve(convertedDataUrl);
2572
2745
  } catch (error) {
2573
2746
  reject(error);
@@ -2893,6 +3066,7 @@ var ImageEditor = class {
2893
3066
  const canUndo = this.historyManager?.canUndo();
2894
3067
  const canRedo = this.historyManager?.canRedo();
2895
3068
  const isInCropMode = !!this._cropMode;
3069
+ const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
2896
3070
  if (isInCropMode) {
2897
3071
  for (const key of Object.keys(this.elements || {})) {
2898
3072
  const element = this._getElement(key);
@@ -2905,23 +3079,23 @@ var ImageEditor = class {
2905
3079
  }
2906
3080
  return;
2907
3081
  }
2908
- this._setDisabled("zoomInBtn", !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2909
- this._setDisabled("zoomOutBtn", !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2910
- this._setDisabled("rotateLeftBtn", !hasImage || this.isAnimating);
2911
- this._setDisabled("rotateRightBtn", !hasImage || this.isAnimating);
2912
- this._setDisabled("addMaskBtn", !hasImage || this.isAnimating);
2913
- this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
2914
- this._setDisabled("removeAllMasksBtn", !hasMasks || this.isAnimating);
2915
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || this.isAnimating);
2916
- this._setDisabled("downloadBtn", !hasImage || this.isAnimating);
2917
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || this.isAnimating);
2918
- this._setDisabled("undoBtn", !hasImage || this.isAnimating || !canUndo);
2919
- this._setDisabled("redoBtn", !hasImage || this.isAnimating || !canRedo);
2920
- this._setDisabled("cropBtn", !hasImage || this.isAnimating);
3082
+ this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3083
+ this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3084
+ this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3085
+ this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3086
+ this._setDisabled("addMaskBtn", !hasImage || isBusy);
3087
+ this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3088
+ this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3089
+ this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3090
+ this._setDisabled("downloadBtn", !hasImage || isBusy);
3091
+ this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3092
+ this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3093
+ this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3094
+ this._setDisabled("cropBtn", !hasImage || isBusy);
2921
3095
  this._setDisabled("applyCropBtn", true);
2922
3096
  this._setDisabled("cancelCropBtn", true);
2923
- this._setDisabled("imageInput", this.isAnimating);
2924
- this._setDisabled("uploadArea", this.isAnimating);
3097
+ this._setDisabled("imageInput", isBusy);
3098
+ this._setDisabled("uploadArea", isBusy);
2925
3099
  }
2926
3100
  /**
2927
3101
  * Enables or disables a specific UI element (typically a button) by its key.
@@ -2937,12 +3111,16 @@ var ImageEditor = class {
2937
3111
  element.disabled = !!disabled;
2938
3112
  return;
2939
3113
  }
3114
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3115
+ if (!this._elementOriginalPointerEvents.has(key)) {
3116
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || "");
3117
+ }
2940
3118
  if (disabled) {
2941
3119
  element.setAttribute("aria-disabled", "true");
2942
3120
  element.style.pointerEvents = "none";
2943
3121
  } else {
2944
3122
  element.removeAttribute("aria-disabled");
2945
- element.style.pointerEvents = "";
3123
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? "";
2946
3124
  }
2947
3125
  }
2948
3126
  _isElementDisabled(element) {
@@ -3028,6 +3206,9 @@ var ImageEditor = class {
3028
3206
  if (this.animationQueue) {
3029
3207
  this.animationQueue.cancelAll(new Error("Editor disposed"));
3030
3208
  }
3209
+ this._isLoading = false;
3210
+ this._activeOperationName = null;
3211
+ this._activeOperationToken = null;
3031
3212
  try {
3032
3213
  for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3033
3214
  const element = this._getElement(key);
@@ -3089,17 +3270,20 @@ var ImageEditor = class {
3089
3270
  }
3090
3271
  this._handlersByElementKey = {};
3091
3272
  this._elementCache = {};
3273
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3092
3274
  this._clearMaskPlacementMemory();
3093
3275
  this.originalImage = null;
3094
3276
  this.baseImageScale = 1;
3095
3277
  this.currentScale = 1;
3096
3278
  this.currentRotation = 0;
3097
3279
  this.isAnimating = false;
3280
+ this._isLoading = false;
3098
3281
  this._cropMode = false;
3099
3282
  this._cropRect = null;
3100
3283
  this._cropHandlers = [];
3101
3284
  this._cropPrevEvented = null;
3102
3285
  this._prevSelectionSetting = void 0;
3286
+ this._lastContainerViewportSize = null;
3103
3287
  this._initialized = false;
3104
3288
  }
3105
3289
  };
@@ -3111,6 +3295,7 @@ var AnimationQueue = class {
3111
3295
  this.animationTasks = [];
3112
3296
  this.isRunning = false;
3113
3297
  this.currentTask = null;
3298
+ this._generation = 0;
3114
3299
  }
3115
3300
  /**
3116
3301
  * Adds an animation function to the queue.
@@ -3130,6 +3315,7 @@ var AnimationQueue = class {
3130
3315
  return this.isRunning || this.animationTasks.length > 0;
3131
3316
  }
3132
3317
  cancelAll(reason = new Error("Animation queue cancelled")) {
3318
+ this._generation += 1;
3133
3319
  const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3134
3320
  const tasks = [
3135
3321
  ...this.currentTask ? [this.currentTask] : [],
@@ -3151,26 +3337,33 @@ var AnimationQueue = class {
3151
3337
  */
3152
3338
  async _drainQueue() {
3153
3339
  if (this.isRunning) return;
3340
+ const generation = this._generation;
3154
3341
  this.isRunning = true;
3155
- while (this.animationTasks.length > 0) {
3156
- const task = this.animationTasks.shift();
3157
- this.currentTask = task;
3158
- try {
3159
- const result = await task.animationFn();
3160
- if (!task.isSettled) {
3161
- task.isSettled = true;
3162
- task.resolve(result);
3163
- }
3164
- } catch (error) {
3165
- if (!task.isSettled) {
3166
- task.isSettled = true;
3167
- task.reject(error);
3342
+ try {
3343
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3344
+ const task = this.animationTasks.shift();
3345
+ this.currentTask = task;
3346
+ try {
3347
+ const result = await task.animationFn();
3348
+ if (generation === this._generation && !task.isSettled) {
3349
+ task.isSettled = true;
3350
+ task.resolve(result);
3351
+ }
3352
+ } catch (error) {
3353
+ if (generation === this._generation && !task.isSettled) {
3354
+ task.isSettled = true;
3355
+ task.reject(error);
3356
+ }
3357
+ } finally {
3358
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3168
3359
  }
3169
- } finally {
3170
- if (this.currentTask === task) this.currentTask = null;
3360
+ }
3361
+ } finally {
3362
+ if (generation === this._generation) {
3363
+ this.isRunning = false;
3364
+ this.currentTask = null;
3171
3365
  }
3172
3366
  }
3173
- this.isRunning = false;
3174
3367
  }
3175
3368
  };
3176
3369
  var Command = class {