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