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