@bensitu/image-editor 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2
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.for("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,19 +566,23 @@ 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 {
558
575
  const imageElement = await this._createImageElement(imageBase64);
559
576
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
560
577
  let loadSource = imageBase64;
561
- if (this.options.downsampleOnLoad) {
562
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
578
+ const downsampleMaxWidth = Number(this.options.downsampleMaxWidth);
579
+ const downsampleMaxHeight = Number(this.options.downsampleMaxHeight);
580
+ if (this.options.downsampleOnLoad && downsampleMaxWidth > 0 && downsampleMaxHeight > 0) {
581
+ const shouldResize = imageElement.naturalWidth > downsampleMaxWidth || imageElement.naturalHeight > downsampleMaxHeight;
563
582
  if (shouldResize) {
564
583
  const ratio = Math.min(
565
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
566
- this.options.downsampleMaxHeight / imageElement.naturalHeight
584
+ downsampleMaxWidth / imageElement.naturalWidth,
585
+ downsampleMaxHeight / imageElement.naturalHeight
567
586
  );
568
587
  const targetWidth = Math.round(imageElement.naturalWidth * ratio);
569
588
  const targetHeight = Math.round(imageElement.naturalHeight * ratio);
@@ -571,10 +590,12 @@ var ImageEditor = class {
571
590
  imageElement,
572
591
  targetWidth,
573
592
  targetHeight,
574
- this.options.downsampleQuality,
593
+ this._normalizeQuality(this.options.downsampleQuality),
575
594
  imageBase64
576
595
  );
577
596
  }
597
+ } else if (this.options.downsampleOnLoad) {
598
+ this._reportWarning("loadImage: downsample limits must be positive numbers; using the original image");
578
599
  }
579
600
  const fabricImage = await this._createFabricImageFromURL(loadSource);
580
601
  if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
@@ -639,6 +660,9 @@ var ImageEditor = class {
639
660
  } catch (error) {
640
661
  await this._rollbackLoadImageTransaction(transaction);
641
662
  throw error;
663
+ } finally {
664
+ this._isLoading = false;
665
+ if (!this._disposed && this.canvas) this._updateUI();
642
666
  }
643
667
  }
644
668
  /**
@@ -649,6 +673,15 @@ var ImageEditor = class {
649
673
  const fabricInstance2 = ensureFabric();
650
674
  return !!(this.originalImage && fabricInstance2 && this.originalImage instanceof fabricInstance2.Image && this.originalImage.width > 0 && this.originalImage.height > 0);
651
675
  }
676
+ /**
677
+ * Checks whether the editor is in a temporary non-mutating state.
678
+ *
679
+ * @returns {boolean} True while loading, animating, cropping, or running a compound operation.
680
+ * @public
681
+ */
682
+ isBusy() {
683
+ return !!(this.isAnimating || this._cropMode || this._isLoading || this._activeOperationToken || this.animationQueue && this.animationQueue.isBusy());
684
+ }
652
685
  /**
653
686
  * Creates an HTMLImageElement from a given data URL.
654
687
  *
@@ -720,7 +753,6 @@ var ImageEditor = class {
720
753
  _captureLoadImageTransaction() {
721
754
  return {
722
755
  canvasState: this._serializeCanvasState(),
723
- originalImage: this.originalImage,
724
756
  baseImageScale: this.baseImageScale,
725
757
  currentScale: this.currentScale,
726
758
  currentRotation: this.currentRotation,
@@ -744,9 +776,14 @@ var ImageEditor = class {
744
776
  }
745
777
  async _rollbackLoadImageTransaction(transaction) {
746
778
  if (!transaction || !this.canvas || this._disposed) return;
779
+ let didRestoreCanvasState = false;
747
780
  try {
748
- if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
781
+ if (transaction.canvasState) {
782
+ await this.loadFromState(transaction.canvasState);
783
+ didRestoreCanvasState = true;
784
+ }
749
785
  } catch (error) {
786
+ this._lastMask = null;
750
787
  this._reportError("loadImage rollback failed", error);
751
788
  }
752
789
  this.baseImageScale = transaction.baseImageScale;
@@ -755,22 +792,40 @@ var ImageEditor = class {
755
792
  this.maskCounter = transaction.maskCounter;
756
793
  this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
757
794
  this._lastSnapshot = transaction.lastSnapshot;
795
+ if (didRestoreCanvasState) {
796
+ this._restoreLastMaskReference(transaction.lastMask);
797
+ } else {
798
+ this._lastMask = null;
799
+ }
758
800
  this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
759
801
  this._lastMaskInitialTop = transaction.lastMaskInitialTop;
760
802
  this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
761
- this._containerOriginalOverflow = transaction.containerOverflow;
762
803
  this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
763
804
  this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
764
805
  if (this.containerElement) {
765
806
  this.containerElement.scrollLeft = transaction.scrollLeft;
766
807
  this.containerElement.scrollTop = transaction.scrollTop;
767
- this._restoreContainerOverflowState();
808
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
768
809
  }
769
810
  this._updateInputs();
770
811
  this._updateMaskList();
771
812
  this._updateUI();
772
813
  if (this.canvas) this.canvas.renderAll();
773
814
  }
815
+ _restoreLastMaskReference(previousLastMask) {
816
+ if (!this.canvas) {
817
+ this._lastMask = null;
818
+ return;
819
+ }
820
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
821
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
822
+ this._lastMask = masks.find((mask) => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
823
+ if (!this._lastMask) {
824
+ this._lastMaskInitialLeft = null;
825
+ this._lastMaskInitialTop = null;
826
+ this._lastMaskInitialWidth = null;
827
+ }
828
+ }
774
829
  /**
775
830
  * Resamples the given image element to a new width and height and returns the result as a data URL.
776
831
  *
@@ -783,12 +838,19 @@ var ImageEditor = class {
783
838
  * @private
784
839
  */
785
840
  _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
841
+ const sourceWidth = Math.max(1, Number(imageElement && (imageElement.naturalWidth || imageElement.width)) || 0);
842
+ const sourceHeight = Math.max(1, Number(imageElement && (imageElement.naturalHeight || imageElement.height)) || 0);
843
+ const safeTargetWidth = Math.round(Number(targetWidth));
844
+ const safeTargetHeight = Math.round(Number(targetHeight));
845
+ if (!Number.isFinite(safeTargetWidth) || !Number.isFinite(safeTargetHeight) || safeTargetWidth <= 0 || safeTargetHeight <= 0) {
846
+ throw new Error("Invalid image resample target dimensions");
847
+ }
786
848
  const offscreenCanvas = document.createElement("canvas");
787
- offscreenCanvas.width = targetWidth;
788
- offscreenCanvas.height = targetHeight;
849
+ offscreenCanvas.width = safeTargetWidth;
850
+ offscreenCanvas.height = safeTargetHeight;
789
851
  const context = offscreenCanvas.getContext("2d");
790
852
  if (!context) throw new Error("2D canvas context is unavailable");
791
- context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
853
+ context.drawImage(imageElement, 0, 0, sourceWidth, sourceHeight, 0, 0, safeTargetWidth, safeTargetHeight);
792
854
  return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
793
855
  }
794
856
  _getDataUrlMimeType(dataUrl) {
@@ -1054,7 +1116,11 @@ var ImageEditor = class {
1054
1116
  maskStyleBackups.push(backup);
1055
1117
  mask.set(stylePatch);
1056
1118
  });
1057
- return callback();
1119
+ const result = callback();
1120
+ if (result && typeof result.then === "function") {
1121
+ throw new Error("_withNormalizedMaskStyles callback must be synchronous");
1122
+ }
1123
+ return result;
1058
1124
  } finally {
1059
1125
  maskStyleBackups.forEach((backup) => {
1060
1126
  try {
@@ -1122,9 +1188,13 @@ var ImageEditor = class {
1122
1188
  * @returns {number} A finite quality value between 0 and 1.
1123
1189
  * @private
1124
1190
  */
1125
- _normalizeQuality(quality) {
1191
+ _normalizeQuality(quality, fallback = void 0) {
1192
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1193
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1194
+ const safeFallback = Number.isFinite(numericFallback) ? Math.max(0, Math.min(1, numericFallback)) : 0.92;
1195
+ if (quality == null) return safeFallback;
1126
1196
  const numericQuality = Number(quality);
1127
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1197
+ if (!Number.isFinite(numericQuality)) return safeFallback;
1128
1198
  return Math.max(0, Math.min(1, numericQuality));
1129
1199
  }
1130
1200
  /**
@@ -1175,64 +1245,63 @@ var ImageEditor = class {
1175
1245
  sourceHeight: Math.max(1, endY - sourceY)
1176
1246
  };
1177
1247
  }
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
- });
1248
+ _hasFractionalCanvasEdge(value) {
1249
+ const numericValue = Number(value);
1250
+ if (!Number.isFinite(numericValue)) return false;
1251
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1252
+ }
1253
+ _getPartialExportEdges(bounds) {
1254
+ if (!bounds) return null;
1255
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1256
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1257
+ if (!isAxisAligned) return null;
1258
+ return {
1259
+ left: this._hasFractionalCanvasEdge(bounds.left),
1260
+ top: this._hasFractionalCanvasEdge(bounds.top),
1261
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1262
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1263
+ };
1264
+ }
1265
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1266
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1267
+ const imageElement = await this._createImageElement(dataUrl);
1268
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1269
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1270
+ const offscreenCanvas = document.createElement("canvas");
1271
+ offscreenCanvas.width = width;
1272
+ offscreenCanvas.height = height;
1273
+ const context = offscreenCanvas.getContext("2d");
1274
+ if (!context) throw new Error("2D canvas context is unavailable");
1275
+ context.drawImage(imageElement, 0, 0, width, height);
1276
+ const imageData = context.getImageData(0, 0, width, height);
1277
+ const pixels = imageData.data;
1278
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1279
+ const index = (y * width + x) * 4;
1280
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1281
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1282
+ pixels[index] = pixels[fallbackIndex];
1283
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1284
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1285
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1286
+ }
1287
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1288
+ pixels[index + 3] = 255;
1289
+ }
1290
+ };
1291
+ if (edges.left && width > 1) {
1292
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1293
+ }
1294
+ if (edges.right && width > 1) {
1295
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1296
+ }
1297
+ if (edges.top && height > 1) {
1298
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1299
+ }
1300
+ if (edges.bottom && height > 1) {
1301
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1302
+ }
1303
+ context.putImageData(imageData, 0, 0);
1304
+ return offscreenCanvas.toDataURL("image/png");
1236
1305
  }
1237
1306
  /**
1238
1307
  * Exports a source region directly through Fabric's region export options.
@@ -1245,13 +1314,16 @@ var ImageEditor = class {
1245
1314
  * @param {number} [region.multiplier=1] - Export multiplier.
1246
1315
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1247
1316
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1317
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1248
1318
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1249
1319
  * @private
1250
1320
  */
1251
- _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1321
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1252
1322
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1253
- return this.canvas.toDataURL({
1254
- format,
1323
+ const safeFormat = this._normalizeImageFormat(format);
1324
+ const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1325
+ let regionDataUrl = this.canvas.toDataURL({
1326
+ format: exportFormat,
1255
1327
  quality,
1256
1328
  multiplier: safeMultiplier,
1257
1329
  left: sourceX,
@@ -1259,6 +1331,61 @@ var ImageEditor = class {
1259
1331
  width: sourceWidth,
1260
1332
  height: sourceHeight
1261
1333
  });
1334
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1335
+ if (safeFormat !== "jpeg") return regionDataUrl;
1336
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1337
+ }
1338
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1339
+ const imageElement = await this._createImageElement(dataUrl);
1340
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1341
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1342
+ const offscreenCanvas = document.createElement("canvas");
1343
+ offscreenCanvas.width = width;
1344
+ offscreenCanvas.height = height;
1345
+ const context = offscreenCanvas.getContext("2d");
1346
+ if (!context) throw new Error("2D canvas context is unavailable");
1347
+ context.fillStyle = this._getJpegBackgroundColor();
1348
+ context.fillRect(0, 0, width, height);
1349
+ context.drawImage(imageElement, 0, 0, width, height);
1350
+ return offscreenCanvas.toDataURL("image/jpeg", this._normalizeQuality(quality));
1351
+ }
1352
+ _getJpegBackgroundColor() {
1353
+ const backgroundColor = String(this.options.backgroundColor || "").trim();
1354
+ if (!backgroundColor || this._isTransparentCssColor(backgroundColor)) return "#ffffff";
1355
+ return backgroundColor;
1356
+ }
1357
+ _isTransparentCssColor(color) {
1358
+ const normalizedColor = String(color || "").trim().toLowerCase();
1359
+ if (!normalizedColor || normalizedColor === "transparent") return true;
1360
+ const hexAlphaMatch = normalizedColor.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/i);
1361
+ if (hexAlphaMatch) {
1362
+ const alpha = hexAlphaMatch[1] || hexAlphaMatch[2];
1363
+ return alpha === "0" || alpha === "00";
1364
+ }
1365
+ const slashAlphaMatch = normalizedColor.match(/^(?:rgba?|hsla?)\([^)]*\/\s*([^)]+)\)$/i);
1366
+ if (slashAlphaMatch) return this._isZeroCssAlpha(slashAlphaMatch[1]);
1367
+ const commaAlphaMatch = normalizedColor.match(/^(?:rgba|hsla)\((.*)\)$/i);
1368
+ if (commaAlphaMatch) {
1369
+ const parts = commaAlphaMatch[1].split(",");
1370
+ if (parts.length >= 4) return this._isZeroCssAlpha(parts[parts.length - 1]);
1371
+ }
1372
+ return false;
1373
+ }
1374
+ _isZeroCssAlpha(alphaValue) {
1375
+ const normalizedAlpha = String(alphaValue || "").trim();
1376
+ if (!normalizedAlpha) return false;
1377
+ if (normalizedAlpha.endsWith("%")) return Number.parseFloat(normalizedAlpha) === 0;
1378
+ return Number(normalizedAlpha) === 0;
1379
+ }
1380
+ _decodeBase64Payload(base64Payload) {
1381
+ const payload = String(base64Payload || "");
1382
+ if (typeof atob === "function") {
1383
+ return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
1384
+ }
1385
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
1386
+ return new Uint8Array(Buffer.from(payload, "base64"));
1387
+ }
1388
+ throw new Error("Base64 decoding is unavailable");
1262
1389
  }
1263
1390
  /**
1264
1391
  * Gets the top-left corner coordinates of the given object.
@@ -1416,17 +1543,70 @@ var ImageEditor = class {
1416
1543
  * @public
1417
1544
  */
1418
1545
  scaleImage(factor, options = {}) {
1419
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1546
+ try {
1547
+ this._assertCanQueueAnimation("scaleImage", options);
1548
+ } catch (error) {
1549
+ return Promise.reject(error);
1550
+ }
1551
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1552
+ if (!this._disposed && this.canvas) this._updateUI();
1553
+ });
1554
+ }
1555
+ _getInternalOperationToken(options) {
1556
+ return options && options[INTERNAL_OPERATION_TOKEN];
1557
+ }
1558
+ _isOwnInternalOperation(options) {
1559
+ const token = this._getInternalOperationToken(options);
1560
+ return !!token && token === this._activeOperationToken;
1420
1561
  }
1421
- _assertIdleForOperation(operationName) {
1562
+ _beginBusyOperation(operationName) {
1563
+ const token = Symbol(operationName);
1564
+ this._activeOperationName = operationName;
1565
+ this._activeOperationToken = token;
1566
+ this._updateUI();
1567
+ return token;
1568
+ }
1569
+ _endBusyOperation(token) {
1570
+ if (token && token === this._activeOperationToken) {
1571
+ this._activeOperationName = null;
1572
+ this._activeOperationToken = null;
1573
+ this._updateUI();
1574
+ }
1575
+ }
1576
+ _withInternalOperationOptions(token, options = {}) {
1577
+ return {
1578
+ ...options,
1579
+ [INTERNAL_OPERATION_TOKEN]: token
1580
+ };
1581
+ }
1582
+ _assertEditorAvailable(operationName) {
1422
1583
  if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1584
+ }
1585
+ _assertIdleForOperation(operationName, options = {}) {
1586
+ this._assertEditorAvailable(operationName);
1587
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1423
1588
  if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1424
1589
  throw new Error(`${operationName} cannot run while an animation is running`);
1425
1590
  }
1591
+ if (this._isLoading && !isOwnInternalOperation) {
1592
+ throw new Error(`${operationName} cannot run while an image is loading`);
1593
+ }
1594
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1595
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1596
+ }
1597
+ }
1598
+ _assertCanQueueAnimation(operationName, options = {}) {
1599
+ this._assertEditorAvailable(operationName);
1600
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1601
+ throw new Error(`${operationName} cannot run while an image is loading`);
1602
+ }
1603
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1604
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1605
+ }
1426
1606
  }
1427
- _canMutateNow(operationName) {
1607
+ _canMutateNow(operationName, options = {}) {
1428
1608
  try {
1429
- this._assertIdleForOperation(operationName);
1609
+ this._assertIdleForOperation(operationName, options);
1430
1610
  return true;
1431
1611
  } catch (error) {
1432
1612
  this._reportError(`${operationName} blocked`, error);
@@ -1531,7 +1711,14 @@ var ImageEditor = class {
1531
1711
  * @public
1532
1712
  */
1533
1713
  rotateImage(degrees, options = {}) {
1534
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1714
+ try {
1715
+ this._assertCanQueueAnimation("rotateImage", options);
1716
+ } catch (error) {
1717
+ return Promise.reject(error);
1718
+ }
1719
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
1720
+ if (!this._disposed && this.canvas) this._updateUI();
1721
+ });
1535
1722
  }
1536
1723
  /**
1537
1724
  * Rotates the original image by a given number of degrees, with animation.
@@ -1593,12 +1780,19 @@ var ImageEditor = class {
1593
1780
  */
1594
1781
  resetImageTransform() {
1595
1782
  if (!this.originalImage) return Promise.resolve();
1783
+ try {
1784
+ this._assertCanQueueAnimation("resetImageTransform");
1785
+ } catch (error) {
1786
+ return Promise.reject(error);
1787
+ }
1596
1788
  return this.animationQueue.add(async () => {
1597
1789
  const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1598
1790
  await this._scaleImageImpl(1, { saveHistory: false });
1599
1791
  await this._rotateImageImpl(0, { saveHistory: false });
1600
1792
  const after = this._captureCanvasStateOrThrow("resetImageTransform");
1601
1793
  this._pushStateTransition(before, after);
1794
+ }).finally(() => {
1795
+ if (!this._disposed && this.canvas) this._updateUI();
1602
1796
  }).catch((error) => {
1603
1797
  this._reportError("resetImageTransform() failed", error);
1604
1798
  throw error;
@@ -1635,6 +1829,9 @@ var ImageEditor = class {
1635
1829
  try {
1636
1830
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1637
1831
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1832
+ if (editorMetadata && Object.prototype.hasOwnProperty.call(editorMetadata, "version") && Number(editorMetadata.version) !== 1) {
1833
+ this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
1834
+ }
1638
1835
  this.canvas.loadFromJSON(state, async () => {
1639
1836
  try {
1640
1837
  if (this._disposed || !this.canvas) {
@@ -1713,22 +1910,46 @@ var ImageEditor = class {
1713
1910
  }
1714
1911
  _waitForImageElementReady(imageElement) {
1715
1912
  if (!imageElement) return Promise.resolve();
1716
- if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1913
+ const hasLoadedDimensions = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) && (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
1914
+ if (hasLoadedDimensions) return Promise.resolve();
1915
+ if (imageElement.complete) return Promise.reject(new Error("Image could not be loaded while restoring state"));
1717
1916
  return new Promise((resolve, reject) => {
1718
1917
  let isSettled = false;
1719
- const timerId = setTimeout(() => {
1720
- settle(() => reject(new Error("Image load timed out while restoring state")));
1721
- }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1918
+ let timerId;
1722
1919
  const settle = (callback) => {
1723
1920
  if (isSettled) return;
1724
1921
  isSettled = true;
1725
1922
  clearTimeout(timerId);
1726
- imageElement.onload = null;
1727
- imageElement.onerror = null;
1923
+ if (typeof imageElement.removeEventListener === "function") {
1924
+ imageElement.removeEventListener("load", handleLoad);
1925
+ imageElement.removeEventListener("error", handleError);
1926
+ } else {
1927
+ imageElement.onload = null;
1928
+ imageElement.onerror = null;
1929
+ }
1728
1930
  callback();
1729
1931
  };
1730
- imageElement.onload = () => settle(resolve);
1731
- imageElement.onerror = (error) => settle(() => reject(error));
1932
+ const handleLoad = () => {
1933
+ const didLoad = (Number(imageElement.naturalWidth) > 0 || Number(imageElement.width) > 0) && (Number(imageElement.naturalHeight) > 0 || Number(imageElement.height) > 0);
1934
+ settle(() => {
1935
+ if (didLoad) {
1936
+ resolve();
1937
+ } else {
1938
+ reject(new Error("Image could not be loaded while restoring state"));
1939
+ }
1940
+ });
1941
+ };
1942
+ const handleError = (error) => settle(() => reject(error instanceof Error ? error : new Error("Image could not be loaded while restoring state")));
1943
+ timerId = setTimeout(() => {
1944
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1945
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1946
+ if (typeof imageElement.addEventListener === "function") {
1947
+ imageElement.addEventListener("load", handleLoad, { once: true });
1948
+ imageElement.addEventListener("error", handleError, { once: true });
1949
+ } else {
1950
+ imageElement.onload = handleLoad;
1951
+ imageElement.onerror = handleError;
1952
+ }
1732
1953
  });
1733
1954
  }
1734
1955
  /**
@@ -1993,6 +2214,10 @@ var ImageEditor = class {
1993
2214
  });
1994
2215
  }
1995
2216
  }
2217
+ if (!mask || typeof mask.set !== "function" || typeof mask.setCoords !== "function") {
2218
+ this._reportWarning("fabricGenerator returned an invalid Fabric object");
2219
+ return null;
2220
+ }
1996
2221
  const styles = maskConfig.styles || {};
1997
2222
  const hasStyle = (property) => Object.prototype.hasOwnProperty.call(styles, property);
1998
2223
  const maskSettings = {
@@ -2081,7 +2306,7 @@ var ImageEditor = class {
2081
2306
  */
2082
2307
  removeAllMasks(options = {}) {
2083
2308
  if (!this.canvas) return;
2084
- if (!this._canMutateNow("removeAllMasks")) return;
2309
+ if (!this._canMutateNow("removeAllMasks", options)) return;
2085
2310
  const saveHistory = options.saveHistory !== false;
2086
2311
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2087
2312
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -2120,6 +2345,93 @@ var ImageEditor = class {
2120
2345
  }
2121
2346
  }
2122
2347
  }
2348
+ _captureMaskLabelBackups(masks) {
2349
+ if (!this.canvas) return [];
2350
+ const canvasObjects = new Set(this.canvas.getObjects());
2351
+ return (masks || []).map((mask) => {
2352
+ const label = mask && mask.__label ? mask.__label : null;
2353
+ return {
2354
+ mask,
2355
+ label,
2356
+ hadLabel: !!label,
2357
+ labelInCanvas: !!label && canvasObjects.has(label),
2358
+ visible: label ? label.visible : void 0
2359
+ };
2360
+ });
2361
+ }
2362
+ _restoreMaskLabelBackups(labelBackups) {
2363
+ if (!this.canvas || !Array.isArray(labelBackups)) return;
2364
+ const canvasObjects = new Set(this.canvas.getObjects());
2365
+ labelBackups.forEach((backup) => {
2366
+ if (!backup || !backup.mask) return;
2367
+ try {
2368
+ if (!backup.hadLabel) {
2369
+ if (backup.mask.__label) this._removeLabelForMask(backup.mask);
2370
+ return;
2371
+ }
2372
+ backup.mask.__label = backup.label;
2373
+ if (!backup.label) return;
2374
+ if (backup.labelInCanvas && !canvasObjects.has(backup.label)) {
2375
+ this.canvas.add(backup.label);
2376
+ canvasObjects.add(backup.label);
2377
+ }
2378
+ if (backup.visible !== void 0) backup.label.set({ visible: backup.visible });
2379
+ if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
2380
+ this._syncMaskLabel(backup.mask);
2381
+ } catch (error) {
2382
+ void error;
2383
+ }
2384
+ });
2385
+ }
2386
+ _captureActiveObjectBackup() {
2387
+ if (!this.canvas) return null;
2388
+ const activeObject = this.canvas.getActiveObject();
2389
+ if (!activeObject) return null;
2390
+ const selectedObjects = typeof activeObject.getObjects === "function" ? activeObject.getObjects() : [activeObject];
2391
+ return { activeObject, selectedObjects };
2392
+ }
2393
+ _restoreActiveObjectBackup(activeObjectBackup) {
2394
+ if (!this.canvas || !activeObjectBackup || !activeObjectBackup.activeObject) return;
2395
+ const canvasObjects = this.canvas.getObjects();
2396
+ const selectedObjects = Array.isArray(activeObjectBackup.selectedObjects) ? activeObjectBackup.selectedObjects : [];
2397
+ const canRestore = selectedObjects.length ? selectedObjects.every((object) => canvasObjects.includes(object)) : canvasObjects.includes(activeObjectBackup.activeObject);
2398
+ if (!canRestore) return;
2399
+ try {
2400
+ this.canvas.setActiveObject(activeObjectBackup.activeObject);
2401
+ } catch (error) {
2402
+ void error;
2403
+ }
2404
+ }
2405
+ _captureMaskExportBackups(masks) {
2406
+ return (masks || []).map((mask) => ({
2407
+ object: mask,
2408
+ visible: mask.visible,
2409
+ opacity: mask.opacity,
2410
+ fill: mask.fill,
2411
+ strokeWidth: mask.strokeWidth,
2412
+ stroke: mask.stroke,
2413
+ selectable: mask.selectable,
2414
+ lockRotation: mask.lockRotation
2415
+ }));
2416
+ }
2417
+ _restoreMaskExportBackups(maskBackups) {
2418
+ (maskBackups || []).forEach((backup) => {
2419
+ try {
2420
+ backup.object.set({
2421
+ visible: backup.visible,
2422
+ opacity: backup.opacity,
2423
+ fill: backup.fill,
2424
+ strokeWidth: backup.strokeWidth,
2425
+ stroke: backup.stroke,
2426
+ selectable: backup.selectable,
2427
+ lockRotation: backup.lockRotation
2428
+ });
2429
+ backup.object.setCoords();
2430
+ } catch (error) {
2431
+ void error;
2432
+ }
2433
+ });
2434
+ }
2123
2435
  /**
2124
2436
  * Returns a stable zero-based creation index for label callbacks.
2125
2437
  *
@@ -2192,10 +2504,14 @@ var ImageEditor = class {
2192
2504
  _hideAllMaskLabels() {
2193
2505
  if (!this.canvas) return;
2194
2506
  const canvasObjects = this.canvas.getObjects();
2507
+ const canvasObjectSet = new Set(canvasObjects);
2195
2508
  const labels = canvasObjects.filter((object) => object.maskLabel);
2196
2509
  labels.forEach((label) => {
2197
2510
  try {
2198
- if (canvasObjects.includes(label)) this.canvas.remove(label);
2511
+ if (canvasObjectSet.has(label)) {
2512
+ this.canvas.remove(label);
2513
+ canvasObjectSet.delete(label);
2514
+ }
2199
2515
  } catch (error) {
2200
2516
  void error;
2201
2517
  }
@@ -2354,18 +2670,36 @@ var ImageEditor = class {
2354
2670
  this._assertIdleForOperation("mergeMasks");
2355
2671
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2356
2672
  if (!masks.length) return;
2673
+ const beforeJson = this._serializeCanvasState();
2674
+ const operationToken = this._beginBusyOperation("mergeMasks");
2357
2675
  this.canvas.discardActiveObject();
2358
2676
  this.canvas.renderAll();
2359
2677
  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 });
2678
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
2679
+ exportImageArea: true,
2680
+ multiplier: this.options.exportMultiplier,
2681
+ fileType: "png"
2682
+ }));
2683
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2684
+ if (this.canvas.getObjects().some((object) => object.maskId)) {
2685
+ throw new Error("Masks could not be removed during merge");
2686
+ }
2687
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2688
+ preserveScroll: true,
2689
+ resetMaskCounter: false
2690
+ }));
2364
2691
  const afterJson = this._serializeCanvasState();
2365
2692
  this._pushStateTransition(beforeJson, afterJson);
2366
2693
  } catch (error) {
2367
2694
  this._reportError("merge error", error);
2695
+ try {
2696
+ await this.loadFromState(beforeJson);
2697
+ } catch (restoreError) {
2698
+ this._reportError("mergeMasks rollback failed", restoreError);
2699
+ }
2368
2700
  throw error;
2701
+ } finally {
2702
+ this._endBusyOperation(operationToken);
2369
2703
  }
2370
2704
  }
2371
2705
  /**
@@ -2416,14 +2750,18 @@ var ImageEditor = class {
2416
2750
  */
2417
2751
  async exportImageBase64(options = {}) {
2418
2752
  if (!this.originalImage) throw new Error("No image loaded");
2419
- this._assertIdleForOperation("exportImageBase64");
2753
+ this._assertIdleForOperation("exportImageBase64", options);
2420
2754
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2421
2755
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2422
2756
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
2423
2757
  const format = this._normalizeImageFormat(options.fileType || options.format);
2424
2758
  if (!exportImageArea) {
2425
2759
  const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
2760
+ const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
2426
2761
  const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
2762
+ const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
2763
+ const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
2764
+ const activeObjectBackup2 = this._captureActiveObjectBackup();
2427
2765
  try {
2428
2766
  masks2.forEach((mask) => {
2429
2767
  mask.set({ visible: false });
@@ -2432,12 +2770,13 @@ var ImageEditor = class {
2432
2770
  this.canvas.renderAll();
2433
2771
  this.originalImage.setCoords();
2434
2772
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2435
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2436
- return this._exportCanvasRegionToDataURL({
2773
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2774
+ return await this._exportCanvasRegionToDataURL({
2437
2775
  ...exportRegion,
2438
2776
  multiplier,
2439
2777
  quality,
2440
- format
2778
+ format,
2779
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2441
2780
  });
2442
2781
  } finally {
2443
2782
  maskVisibilityBackups.forEach((backup) => {
@@ -2447,19 +2786,16 @@ var ImageEditor = class {
2447
2786
  void error;
2448
2787
  }
2449
2788
  });
2789
+ this._restoreMaskExportBackups(maskStyleBackups2);
2790
+ this._restoreMaskLabelBackups(labelBackups2);
2791
+ this._restoreActiveObjectBackup(activeObjectBackup2);
2450
2792
  this.canvas.renderAll();
2451
2793
  }
2452
2794
  }
2453
2795
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2454
- const maskStyleBackups = masks.map((mask) => ({
2455
- object: mask,
2456
- opacity: mask.opacity,
2457
- fill: mask.fill,
2458
- strokeWidth: mask.strokeWidth,
2459
- stroke: mask.stroke,
2460
- selectable: mask.selectable,
2461
- lockRotation: mask.lockRotation
2462
- }));
2796
+ const maskStyleBackups = this._captureMaskExportBackups(masks);
2797
+ const labelBackups = this._captureMaskLabelBackups(masks);
2798
+ const activeObjectBackup = this._captureActiveObjectBackup();
2463
2799
  let finalBase64;
2464
2800
  try {
2465
2801
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -2472,29 +2808,18 @@ var ImageEditor = class {
2472
2808
  this.canvas.renderAll();
2473
2809
  this.originalImage.setCoords();
2474
2810
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2475
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2476
- finalBase64 = this._exportCanvasRegionToDataURL({
2811
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2812
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2477
2813
  ...exportRegion,
2478
2814
  multiplier,
2479
2815
  quality,
2480
- format
2816
+ format,
2817
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2481
2818
  });
2482
2819
  } finally {
2483
- maskStyleBackups.forEach((backup) => {
2484
- try {
2485
- backup.object.set({
2486
- opacity: backup.opacity,
2487
- fill: backup.fill,
2488
- strokeWidth: backup.strokeWidth,
2489
- stroke: backup.stroke,
2490
- selectable: backup.selectable,
2491
- lockRotation: backup.lockRotation
2492
- });
2493
- backup.object.setCoords();
2494
- } catch (error) {
2495
- void error;
2496
- }
2497
- });
2820
+ this._restoreMaskExportBackups(maskStyleBackups);
2821
+ this._restoreMaskLabelBackups(labelBackups);
2822
+ this._restoreActiveObjectBackup(activeObjectBackup);
2498
2823
  this.canvas.renderAll();
2499
2824
  }
2500
2825
  return finalBase64;
@@ -2538,19 +2863,20 @@ var ImageEditor = class {
2538
2863
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
2539
2864
  } = options;
2540
2865
  const safeFileType = this._normalizeImageFormat(fileType);
2866
+ const normalizedQuality = this._normalizeQuality(quality);
2541
2867
  let imageBase64;
2542
2868
  if (mergeMask) {
2543
2869
  imageBase64 = await this.exportImageBase64({
2544
2870
  exportImageArea: true,
2545
2871
  multiplier,
2546
- quality,
2872
+ quality: normalizedQuality,
2547
2873
  fileType: safeFileType
2548
2874
  });
2549
2875
  } else {
2550
2876
  imageBase64 = await this.exportImageBase64({
2551
2877
  exportImageArea: false,
2552
2878
  multiplier,
2553
- quality,
2879
+ quality: normalizedQuality,
2554
2880
  fileType: safeFileType
2555
2881
  });
2556
2882
  }
@@ -2567,7 +2893,7 @@ var ImageEditor = class {
2567
2893
  const context = offscreenCanvas.getContext("2d");
2568
2894
  if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2569
2895
  context.drawImage(imageElement, 0, 0);
2570
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2896
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2571
2897
  resolve(convertedDataUrl);
2572
2898
  } catch (error) {
2573
2899
  reject(error);
@@ -2577,13 +2903,8 @@ var ImageEditor = class {
2577
2903
  imageElement.src = imageBase64;
2578
2904
  });
2579
2905
  }
2580
- const binaryString = atob(imageDataUrl.split(",")[1]);
2906
+ const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
2581
2907
  const mime = `image/${safeFileType}`;
2582
- let byteIndex = binaryString.length;
2583
- const bytes = new Uint8Array(byteIndex);
2584
- while (byteIndex--) {
2585
- bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
2586
- }
2587
2908
  return new File([bytes], fileName, { type: mime });
2588
2909
  }
2589
2910
  _clearMaskPlacementMemory() {
@@ -2730,6 +3051,30 @@ var ImageEditor = class {
2730
3051
  const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2731
3052
  cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2732
3053
  cropRect.setCoords();
3054
+ const cropBounds = cropRect.getBoundingRect(true, true);
3055
+ const imageLeft = Number(imageBounds.left) || 0;
3056
+ const imageTop = Number(imageBounds.top) || 0;
3057
+ const imageRight = imageLeft + (Number(imageBounds.width) || 0);
3058
+ const imageBottom = imageTop + (Number(imageBounds.height) || 0);
3059
+ let deltaX = 0;
3060
+ let deltaY = 0;
3061
+ if (cropBounds.left < imageLeft) {
3062
+ deltaX = imageLeft - cropBounds.left;
3063
+ } else if (cropBounds.left + cropBounds.width > imageRight) {
3064
+ deltaX = imageRight - (cropBounds.left + cropBounds.width);
3065
+ }
3066
+ if (cropBounds.top < imageTop) {
3067
+ deltaY = imageTop - cropBounds.top;
3068
+ } else if (cropBounds.top + cropBounds.height > imageBottom) {
3069
+ deltaY = imageBottom - (cropBounds.top + cropBounds.height);
3070
+ }
3071
+ if (deltaX || deltaY) {
3072
+ cropRect.set({
3073
+ left: (Number(cropRect.left) || 0) + deltaX,
3074
+ top: (Number(cropRect.top) || 0) + deltaY
3075
+ });
3076
+ cropRect.setCoords();
3077
+ }
2733
3078
  this.canvas.requestRenderAll();
2734
3079
  } catch (error) {
2735
3080
  void error;
@@ -2797,19 +3142,15 @@ var ImageEditor = class {
2797
3142
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2798
3143
  if (masks && masks.length) {
2799
3144
  masks.forEach((mask) => {
2800
- try {
2801
- mask.setCoords();
2802
- const maskBounds = mask.getBoundingRect(true, true);
2803
- const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
2804
- this._removeLabelForMask(mask);
2805
- this.canvas.remove(mask);
2806
- if (shouldPreserveMasks && intersectsCrop) {
2807
- this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2808
- mask.set({ visible: true });
2809
- preservedMasks.push(mask);
2810
- }
2811
- } catch (error) {
2812
- this._reportWarning("applyCrop: failed to remove mask", error);
3145
+ mask.setCoords();
3146
+ const maskBounds = mask.getBoundingRect(true, true);
3147
+ const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
3148
+ this._removeLabelForMask(mask);
3149
+ this.canvas.remove(mask);
3150
+ if (shouldPreserveMasks && intersectsCrop) {
3151
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
3152
+ mask.set({ visible: true });
3153
+ preservedMasks.push(mask);
2813
3154
  }
2814
3155
  });
2815
3156
  this._clearMaskPlacementMemory();
@@ -2817,7 +3158,8 @@ var ImageEditor = class {
2817
3158
  this.canvas.renderAll();
2818
3159
  }
2819
3160
  } catch (error) {
2820
- this._reportWarning("applyCrop: error while removing masks", error);
3161
+ await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error);
3162
+ return;
2821
3163
  }
2822
3164
  this._removeCropRect();
2823
3165
  this._cropMode = false;
@@ -2893,6 +3235,7 @@ var ImageEditor = class {
2893
3235
  const canUndo = this.historyManager?.canUndo();
2894
3236
  const canRedo = this.historyManager?.canRedo();
2895
3237
  const isInCropMode = !!this._cropMode;
3238
+ const isBusy = this.isBusy();
2896
3239
  if (isInCropMode) {
2897
3240
  for (const key of Object.keys(this.elements || {})) {
2898
3241
  const element = this._getElement(key);
@@ -2905,23 +3248,27 @@ var ImageEditor = class {
2905
3248
  }
2906
3249
  return;
2907
3250
  }
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);
3251
+ this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3252
+ this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3253
+ this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3254
+ this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3255
+ this._setDisabled("addMaskBtn", !hasImage || isBusy);
3256
+ this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3257
+ this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3258
+ this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3259
+ this._setDisabled("downloadBtn", !hasImage || isBusy);
3260
+ this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3261
+ this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3262
+ this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3263
+ this._setDisabled("cropBtn", !hasImage || isBusy);
2921
3264
  this._setDisabled("applyCropBtn", true);
2922
3265
  this._setDisabled("cancelCropBtn", true);
2923
- this._setDisabled("imageInput", this.isAnimating);
2924
- this._setDisabled("uploadArea", this.isAnimating);
3266
+ this._setDisabled("scaleRate", !hasImage || isBusy);
3267
+ this._setDisabled("rotationLeftInput", !hasImage || isBusy);
3268
+ this._setDisabled("rotationRightInput", !hasImage || isBusy);
3269
+ this._setDisabled("maskList", !hasImage || isBusy);
3270
+ this._setDisabled("imageInput", isBusy);
3271
+ this._setDisabled("uploadArea", isBusy);
2925
3272
  }
2926
3273
  /**
2927
3274
  * Enables or disables a specific UI element (typically a button) by its key.
@@ -2937,12 +3284,16 @@ var ImageEditor = class {
2937
3284
  element.disabled = !!disabled;
2938
3285
  return;
2939
3286
  }
3287
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3288
+ if (!this._elementOriginalPointerEvents.has(key)) {
3289
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || "");
3290
+ }
2940
3291
  if (disabled) {
2941
3292
  element.setAttribute("aria-disabled", "true");
2942
3293
  element.style.pointerEvents = "none";
2943
3294
  } else {
2944
3295
  element.removeAttribute("aria-disabled");
2945
- element.style.pointerEvents = "";
3296
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? "";
2946
3297
  }
2947
3298
  }
2948
3299
  _isElementDisabled(element) {
@@ -3028,6 +3379,9 @@ var ImageEditor = class {
3028
3379
  if (this.animationQueue) {
3029
3380
  this.animationQueue.cancelAll(new Error("Editor disposed"));
3030
3381
  }
3382
+ this._isLoading = false;
3383
+ this._activeOperationName = null;
3384
+ this._activeOperationToken = null;
3031
3385
  try {
3032
3386
  for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3033
3387
  const element = this._getElement(key);
@@ -3089,17 +3443,20 @@ var ImageEditor = class {
3089
3443
  }
3090
3444
  this._handlersByElementKey = {};
3091
3445
  this._elementCache = {};
3446
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3092
3447
  this._clearMaskPlacementMemory();
3093
3448
  this.originalImage = null;
3094
3449
  this.baseImageScale = 1;
3095
3450
  this.currentScale = 1;
3096
3451
  this.currentRotation = 0;
3097
3452
  this.isAnimating = false;
3453
+ this._isLoading = false;
3098
3454
  this._cropMode = false;
3099
3455
  this._cropRect = null;
3100
3456
  this._cropHandlers = [];
3101
3457
  this._cropPrevEvented = null;
3102
3458
  this._prevSelectionSetting = void 0;
3459
+ this._lastContainerViewportSize = null;
3103
3460
  this._initialized = false;
3104
3461
  }
3105
3462
  };
@@ -3111,6 +3468,7 @@ var AnimationQueue = class {
3111
3468
  this.animationTasks = [];
3112
3469
  this.isRunning = false;
3113
3470
  this.currentTask = null;
3471
+ this._generation = 0;
3114
3472
  }
3115
3473
  /**
3116
3474
  * Adds an animation function to the queue.
@@ -3130,6 +3488,7 @@ var AnimationQueue = class {
3130
3488
  return this.isRunning || this.animationTasks.length > 0;
3131
3489
  }
3132
3490
  cancelAll(reason = new Error("Animation queue cancelled")) {
3491
+ this._generation += 1;
3133
3492
  const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3134
3493
  const tasks = [
3135
3494
  ...this.currentTask ? [this.currentTask] : [],
@@ -3151,26 +3510,33 @@ var AnimationQueue = class {
3151
3510
  */
3152
3511
  async _drainQueue() {
3153
3512
  if (this.isRunning) return;
3513
+ const generation = this._generation;
3154
3514
  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);
3515
+ try {
3516
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3517
+ const task = this.animationTasks.shift();
3518
+ this.currentTask = task;
3519
+ try {
3520
+ const result = await task.animationFn();
3521
+ if (generation === this._generation && !task.isSettled) {
3522
+ task.isSettled = true;
3523
+ task.resolve(result);
3524
+ }
3525
+ } catch (error) {
3526
+ if (generation === this._generation && !task.isSettled) {
3527
+ task.isSettled = true;
3528
+ task.reject(error);
3529
+ }
3530
+ } finally {
3531
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3168
3532
  }
3169
- } finally {
3170
- if (this.currentTask === task) this.currentTask = null;
3533
+ }
3534
+ } finally {
3535
+ if (generation === this._generation) {
3536
+ this.isRunning = false;
3537
+ this.currentTask = null;
3171
3538
  }
3172
3539
  }
3173
- this.isRunning = false;
3174
3540
  }
3175
3541
  };
3176
3542
  var Command = class {
@@ -3215,9 +3581,9 @@ var HistoryManager = class {
3215
3581
  execute(command) {
3216
3582
  const result = command.execute();
3217
3583
  if (result && typeof result.then === "function") {
3218
- return Promise.resolve(result).then(() => {
3584
+ return this.enqueue(() => Promise.resolve(result).then(() => {
3219
3585
  this.push(command);
3220
- });
3586
+ }));
3221
3587
  }
3222
3588
  this.push(command);
3223
3589
  return result;