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