@bensitu/image-editor 1.5.0 → 1.5.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/README.md +67 -32
- package/dist/image-editor.cjs +4185 -0
- package/dist/image-editor.cjs.map +7 -0
- package/dist/image-editor.esm.js +576 -259
- package/dist/image-editor.esm.js.map +3 -3
- 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 +576 -259
- package/dist/image-editor.esm.mjs.map +3 -3
- package/dist/image-editor.js +576 -259
- package/dist/image-editor.js.map +3 -3
- package/dist/image-editor.min.js +2 -2
- package/dist/image-editor.min.js.map +3 -3
- package/image-editor.d.ts +4 -3
- package/package.json +4 -3
- package/src/image-editor.js +502 -146
package/dist/image-editor.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file image-editor.js
|
|
5
5
|
* @module image-editor
|
|
6
|
-
* @version 1.5.
|
|
6
|
+
* @version 1.5.1
|
|
7
7
|
* @author Ben Situ
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
downsampleMimeType: null,
|
|
76
76
|
imageLoadTimeoutMs: 3e4,
|
|
77
77
|
exportMultiplier: 1,
|
|
78
|
+
maxExportPixels: 5e7,
|
|
78
79
|
exportImageAreaByDefault: true,
|
|
79
80
|
defaultMaskWidth: 50,
|
|
80
81
|
defaultMaskHeight: 80,
|
|
@@ -144,6 +145,8 @@
|
|
|
144
145
|
this._activeAnimationRejectors = /* @__PURE__ */ new Set();
|
|
145
146
|
this._disposed = false;
|
|
146
147
|
this._initialized = false;
|
|
148
|
+
this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
|
|
149
|
+
this._cropRotationWarningEmitted = false;
|
|
147
150
|
this.onImageLoaded = typeof this.options.onImageLoaded === "function" ? this.options.onImageLoaded : null;
|
|
148
151
|
this.animationQueue = new AnimationQueue();
|
|
149
152
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
@@ -208,7 +211,13 @@
|
|
|
208
211
|
* });
|
|
209
212
|
*/
|
|
210
213
|
init(idMap = {}) {
|
|
211
|
-
if (!this._fabricLoaded)
|
|
214
|
+
if (!this._fabricLoaded) {
|
|
215
|
+
this._fabricLoaded = !!ensureFabric();
|
|
216
|
+
if (!this._fabricLoaded) {
|
|
217
|
+
this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
212
221
|
if (this._initialized || this.canvas) this.dispose();
|
|
213
222
|
this._disposed = false;
|
|
214
223
|
this._initialized = true;
|
|
@@ -223,7 +232,6 @@
|
|
|
223
232
|
this._containerOriginalOverflow = null;
|
|
224
233
|
this._lastContainerViewportSize = null;
|
|
225
234
|
this._canvasElementOriginalStyle = null;
|
|
226
|
-
this._deprecatedElementKeyWarnings = /* @__PURE__ */ new Set();
|
|
227
235
|
const defaults = {
|
|
228
236
|
canvas: "fabricCanvas",
|
|
229
237
|
canvasContainer: null,
|
|
@@ -262,6 +270,7 @@
|
|
|
262
270
|
redoButton: "redoButton",
|
|
263
271
|
redoBtn: null,
|
|
264
272
|
imageInput: "imageInput",
|
|
273
|
+
uploadArea: null,
|
|
265
274
|
enterCropModeButton: "enterCropModeButton",
|
|
266
275
|
cropBtn: null,
|
|
267
276
|
applyCropButton: "applyCropButton",
|
|
@@ -277,7 +286,7 @@
|
|
|
277
286
|
this._updateMaskList();
|
|
278
287
|
this._updateUI();
|
|
279
288
|
if (this.options.initialImageBase64) {
|
|
280
|
-
this.loadImage(this.options.initialImageBase64);
|
|
289
|
+
this.loadImage(this.options.initialImageBase64).catch((error) => this._reportError("initialImageBase64 could not be loaded", error));
|
|
281
290
|
} else {
|
|
282
291
|
this._updatePlaceholderStatus();
|
|
283
292
|
}
|
|
@@ -478,13 +487,14 @@
|
|
|
478
487
|
if (!this.containerElement || !this.containerElement.style) return;
|
|
479
488
|
this._captureContainerOverflowState();
|
|
480
489
|
const shouldPreserveScroll = options.preserveScroll === true;
|
|
481
|
-
|
|
490
|
+
const layoutMode = this._getImageLayoutMode();
|
|
491
|
+
if (layoutMode === "cover") {
|
|
482
492
|
this.containerElement.style.overflow = "scroll";
|
|
483
493
|
if (!shouldPreserveScroll) {
|
|
484
494
|
this.containerElement.scrollLeft = 0;
|
|
485
495
|
this.containerElement.scrollTop = 0;
|
|
486
496
|
}
|
|
487
|
-
} else if (
|
|
497
|
+
} else if (layoutMode === "fit") {
|
|
488
498
|
this.containerElement.style.overflow = "auto";
|
|
489
499
|
if (!shouldPreserveScroll) {
|
|
490
500
|
this.containerElement.scrollLeft = 0;
|
|
@@ -635,6 +645,12 @@
|
|
|
635
645
|
`Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
|
|
636
646
|
);
|
|
637
647
|
}
|
|
648
|
+
_getImageLayoutMode() {
|
|
649
|
+
if (this.options.fitImageToCanvas) return "fit";
|
|
650
|
+
if (this.options.coverImageToCanvas) return "cover";
|
|
651
|
+
if (this.options.expandCanvasToImage) return "expand";
|
|
652
|
+
return "contain";
|
|
653
|
+
}
|
|
638
654
|
/**
|
|
639
655
|
* Loads a base64 data URL into the Fabric canvas as the base image.
|
|
640
656
|
*
|
|
@@ -648,12 +664,16 @@
|
|
|
648
664
|
if (!this._fabricLoaded) return;
|
|
649
665
|
if (!this.canvas || this._disposed) return;
|
|
650
666
|
if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
|
|
667
|
+
options = options || {};
|
|
651
668
|
this._assertIdleForOperation("loadImage", options);
|
|
652
|
-
|
|
653
|
-
this.
|
|
654
|
-
|
|
655
|
-
const transaction = this._captureLoadImageTransaction();
|
|
669
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
670
|
+
const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("loadImage");
|
|
671
|
+
let transaction = null;
|
|
656
672
|
try {
|
|
673
|
+
this._isLoading = true;
|
|
674
|
+
this._updateUI();
|
|
675
|
+
this._warnOnImageLayoutOptionConflict();
|
|
676
|
+
transaction = this._captureLoadImageTransaction();
|
|
657
677
|
const imageElement = await this._createImageElement(imageBase64);
|
|
658
678
|
if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
|
|
659
679
|
let loadSource = imageBase64;
|
|
@@ -693,7 +713,8 @@
|
|
|
693
713
|
const viewport = this._getContainerViewportSize();
|
|
694
714
|
const minWidth = viewport.width;
|
|
695
715
|
const minHeight = viewport.height;
|
|
696
|
-
|
|
716
|
+
const layoutMode = this._getImageLayoutMode();
|
|
717
|
+
if (layoutMode === "fit") {
|
|
697
718
|
const canvasWidth = Math.max(1, minWidth - 1);
|
|
698
719
|
const canvasHeight = Math.max(1, minHeight - 1);
|
|
699
720
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
@@ -701,13 +722,13 @@
|
|
|
701
722
|
fabricImage.set({ left: 0, top: 0 });
|
|
702
723
|
fabricImage.scale(fitScale);
|
|
703
724
|
this.baseImageScale = fabricImage.scaleX || 1;
|
|
704
|
-
} else if (
|
|
725
|
+
} else if (layoutMode === "cover") {
|
|
705
726
|
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
706
727
|
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
707
728
|
fabricImage.set({ left: 0, top: 0 });
|
|
708
729
|
fabricImage.scale(layout.scale);
|
|
709
730
|
this.baseImageScale = fabricImage.scaleX || 1;
|
|
710
|
-
} else if (
|
|
731
|
+
} else if (layoutMode === "expand") {
|
|
711
732
|
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
712
733
|
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
713
734
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
@@ -738,10 +759,14 @@
|
|
|
738
759
|
this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
|
|
739
760
|
this._notifyImageLoaded();
|
|
740
761
|
} catch (error) {
|
|
741
|
-
await this._rollbackLoadImageTransaction(
|
|
762
|
+
await this._rollbackLoadImageTransaction(
|
|
763
|
+
transaction,
|
|
764
|
+
this._withInternalOperationOptions(operationToken)
|
|
765
|
+
);
|
|
742
766
|
throw error;
|
|
743
767
|
} finally {
|
|
744
768
|
this._isLoading = false;
|
|
769
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
745
770
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
746
771
|
}
|
|
747
772
|
}
|
|
@@ -854,13 +879,13 @@
|
|
|
854
879
|
canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
|
|
855
880
|
};
|
|
856
881
|
}
|
|
857
|
-
async _rollbackLoadImageTransaction(transaction) {
|
|
882
|
+
async _rollbackLoadImageTransaction(transaction, options = {}) {
|
|
858
883
|
if (!transaction || !this.canvas || this._disposed) return;
|
|
859
884
|
let didRestoreCanvasState = false;
|
|
860
885
|
let didFailCanvasRestore = false;
|
|
861
886
|
try {
|
|
862
887
|
if (transaction.canvasState) {
|
|
863
|
-
await this.loadFromState(transaction.canvasState);
|
|
888
|
+
await this.loadFromState(transaction.canvasState, options);
|
|
864
889
|
didRestoreCanvasState = true;
|
|
865
890
|
}
|
|
866
891
|
} catch (error) {
|
|
@@ -1110,9 +1135,9 @@
|
|
|
1110
1135
|
}
|
|
1111
1136
|
_getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
|
|
1112
1137
|
if (this._hasFixedContainerScrollbars()) {
|
|
1113
|
-
const
|
|
1114
|
-
const safeWidth = Math.max(1, viewport.width -
|
|
1115
|
-
const safeHeight = Math.max(1, viewport.height -
|
|
1138
|
+
const safetyMargin2 = this._getScrollSafetyMargin();
|
|
1139
|
+
const safeWidth = Math.max(1, viewport.width - safetyMargin2);
|
|
1140
|
+
const safeHeight = Math.max(1, viewport.height - safetyMargin2);
|
|
1116
1141
|
return {
|
|
1117
1142
|
width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
|
|
1118
1143
|
height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
|
|
@@ -1138,9 +1163,17 @@
|
|
|
1138
1163
|
}
|
|
1139
1164
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
1140
1165
|
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
1166
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
1167
|
+
const layoutMode = this._getImageLayoutMode();
|
|
1168
|
+
const shouldReserveNoScrollbarMargin = layoutMode === "fit" || layoutMode === "cover";
|
|
1169
|
+
const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
|
|
1170
|
+
const margin = hasOppositeScrollbar ? safetyMargin : shouldReserveNoScrollbarMargin ? 1 : 0;
|
|
1171
|
+
const safeEffectiveSize = Math.max(1, effectiveSize - margin);
|
|
1172
|
+
return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
|
|
1173
|
+
};
|
|
1141
1174
|
return {
|
|
1142
|
-
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
|
|
1143
|
-
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
|
|
1175
|
+
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
|
|
1176
|
+
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
|
|
1144
1177
|
viewportWidth: effectiveWidth,
|
|
1145
1178
|
viewportHeight: effectiveHeight,
|
|
1146
1179
|
hasHorizontal,
|
|
@@ -1262,6 +1295,45 @@
|
|
|
1262
1295
|
});
|
|
1263
1296
|
}
|
|
1264
1297
|
}
|
|
1298
|
+
_getSerializableStateObjects() {
|
|
1299
|
+
if (!this.canvas) return [];
|
|
1300
|
+
return this.canvas.getObjects().filter((object) => !object.isCropRect && !object.maskLabel);
|
|
1301
|
+
}
|
|
1302
|
+
_restoreHighPrecisionSerializedGeometry(serializedObjects) {
|
|
1303
|
+
if (!Array.isArray(serializedObjects)) return;
|
|
1304
|
+
const fabricObjects = this._getSerializableStateObjects();
|
|
1305
|
+
const numericProperties = [
|
|
1306
|
+
"left",
|
|
1307
|
+
"top",
|
|
1308
|
+
"width",
|
|
1309
|
+
"height",
|
|
1310
|
+
"scaleX",
|
|
1311
|
+
"scaleY",
|
|
1312
|
+
"angle",
|
|
1313
|
+
"skewX",
|
|
1314
|
+
"skewY",
|
|
1315
|
+
"cropX",
|
|
1316
|
+
"cropY",
|
|
1317
|
+
"radius",
|
|
1318
|
+
"rx",
|
|
1319
|
+
"ry",
|
|
1320
|
+
"strokeWidth"
|
|
1321
|
+
];
|
|
1322
|
+
serializedObjects.forEach((serializedObject, index) => {
|
|
1323
|
+
const fabricObject = fabricObjects[index];
|
|
1324
|
+
if (!serializedObject || !fabricObject) return;
|
|
1325
|
+
numericProperties.forEach((property) => {
|
|
1326
|
+
const numericValue = Number(fabricObject[property]);
|
|
1327
|
+
if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
|
|
1328
|
+
});
|
|
1329
|
+
if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
|
|
1330
|
+
serializedObject.points = fabricObject.points.map((point) => ({
|
|
1331
|
+
x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
|
|
1332
|
+
y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
|
|
1333
|
+
}));
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1265
1337
|
_restoreMaskControls(mask) {
|
|
1266
1338
|
if (!mask) return;
|
|
1267
1339
|
const cornerSize = Number(mask.cornerSize);
|
|
@@ -1307,6 +1379,7 @@
|
|
|
1307
1379
|
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
1308
1380
|
if (Array.isArray(jsonObject.objects)) {
|
|
1309
1381
|
jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
|
|
1382
|
+
this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
|
|
1310
1383
|
}
|
|
1311
1384
|
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
1312
1385
|
return JSON.stringify(jsonObject);
|
|
@@ -1381,6 +1454,12 @@
|
|
|
1381
1454
|
if (!Number.isFinite(numericValue)) return false;
|
|
1382
1455
|
return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
|
|
1383
1456
|
}
|
|
1457
|
+
_hasScaledImageEdge(axis) {
|
|
1458
|
+
if (!this.originalImage) return false;
|
|
1459
|
+
const scale = Number(axis === "y" ? this.originalImage.scaleY : this.originalImage.scaleX);
|
|
1460
|
+
if (!Number.isFinite(scale)) return false;
|
|
1461
|
+
return Math.abs(scale - 1) > 0.01;
|
|
1462
|
+
}
|
|
1384
1463
|
_getPartialExportEdges(bounds) {
|
|
1385
1464
|
if (!bounds) return null;
|
|
1386
1465
|
const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
|
|
@@ -1389,8 +1468,8 @@
|
|
|
1389
1468
|
return {
|
|
1390
1469
|
left: this._hasFractionalCanvasEdge(bounds.left),
|
|
1391
1470
|
top: this._hasFractionalCanvasEdge(bounds.top),
|
|
1392
|
-
right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
|
|
1393
|
-
bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
|
|
1471
|
+
right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge("x"),
|
|
1472
|
+
bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge("y")
|
|
1394
1473
|
};
|
|
1395
1474
|
}
|
|
1396
1475
|
async _sealPartialTransparentEdges(dataUrl, edges) {
|
|
@@ -1450,7 +1529,8 @@
|
|
|
1450
1529
|
* @private
|
|
1451
1530
|
*/
|
|
1452
1531
|
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
|
|
1453
|
-
const safeMultiplier =
|
|
1532
|
+
const safeMultiplier = this._getSafeExportMultiplier(multiplier);
|
|
1533
|
+
this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
|
|
1454
1534
|
const safeFormat = this._normalizeImageFormat(format);
|
|
1455
1535
|
const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
|
|
1456
1536
|
let regionDataUrl = this.canvas.toDataURL({
|
|
@@ -1466,6 +1546,25 @@
|
|
|
1466
1546
|
if (safeFormat !== "jpeg") return regionDataUrl;
|
|
1467
1547
|
return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
|
|
1468
1548
|
}
|
|
1549
|
+
_getSafeExportMultiplier(multiplier) {
|
|
1550
|
+
const numericMultiplier = Number(multiplier);
|
|
1551
|
+
if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
|
|
1552
|
+
throw new Error("Export multiplier must be a finite positive number");
|
|
1553
|
+
}
|
|
1554
|
+
return Math.max(1, numericMultiplier);
|
|
1555
|
+
}
|
|
1556
|
+
_assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
|
|
1557
|
+
const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
|
|
1558
|
+
const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
|
|
1559
|
+
const outputWidth = Math.ceil(width * safeMultiplier);
|
|
1560
|
+
const outputHeight = Math.ceil(height * safeMultiplier);
|
|
1561
|
+
const outputPixels = outputWidth * outputHeight;
|
|
1562
|
+
const configuredMaxPixels = Number(this.options.maxExportPixels);
|
|
1563
|
+
const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0 ? Math.floor(configuredMaxPixels) : 5e7;
|
|
1564
|
+
if (outputPixels > maxPixels) {
|
|
1565
|
+
throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1469
1568
|
async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
|
|
1470
1569
|
const imageElement = await this._createImageElement(dataUrl);
|
|
1471
1570
|
const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
|
|
@@ -1510,6 +1609,7 @@
|
|
|
1510
1609
|
}
|
|
1511
1610
|
_decodeBase64Payload(base64Payload) {
|
|
1512
1611
|
const payload = String(base64Payload || "");
|
|
1612
|
+
if (!payload) throw new Error("Data URL base64 payload is empty");
|
|
1513
1613
|
if (typeof atob === "function") {
|
|
1514
1614
|
return Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
|
|
1515
1615
|
}
|
|
@@ -1518,6 +1618,13 @@
|
|
|
1518
1618
|
}
|
|
1519
1619
|
throw new Error("Base64 decoding is unavailable");
|
|
1520
1620
|
}
|
|
1621
|
+
_decodeDataUrlPayload(dataUrl) {
|
|
1622
|
+
const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
|
|
1623
|
+
if (!match || !match[2]) {
|
|
1624
|
+
throw new Error("Export produced an invalid or empty base64 data URL");
|
|
1625
|
+
}
|
|
1626
|
+
return this._decodeBase64Payload(match[2]);
|
|
1627
|
+
}
|
|
1521
1628
|
/**
|
|
1522
1629
|
* Gets the top-left corner coordinates of the given object.
|
|
1523
1630
|
* Used for geometry calculations (e.g., scale, rotate).
|
|
@@ -1627,13 +1734,42 @@
|
|
|
1627
1734
|
const currentHeight = this.canvas.getHeight();
|
|
1628
1735
|
let requiredWidth = currentWidth;
|
|
1629
1736
|
let requiredHeight = currentHeight;
|
|
1630
|
-
|
|
1737
|
+
const layoutMode = this._getImageLayoutMode();
|
|
1738
|
+
const usesScrollableFitBounds = layoutMode === "fit" || layoutMode === "cover";
|
|
1739
|
+
let contentWidth = 0;
|
|
1740
|
+
let contentHeight = 0;
|
|
1741
|
+
const includeObjectBounds = (fabricObject, objectPadding = 0) => {
|
|
1631
1742
|
if (!fabricObject) return;
|
|
1632
1743
|
if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
|
|
1633
1744
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1634
|
-
|
|
1635
|
-
|
|
1745
|
+
const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
|
|
1746
|
+
const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
|
|
1747
|
+
contentWidth = Math.max(contentWidth, right);
|
|
1748
|
+
contentHeight = Math.max(contentHeight, bottom);
|
|
1749
|
+
return { right, bottom };
|
|
1750
|
+
};
|
|
1751
|
+
fabricObjects.forEach((fabricObject) => {
|
|
1752
|
+
const bounds = includeObjectBounds(fabricObject, padding);
|
|
1753
|
+
if (!bounds) return;
|
|
1754
|
+
requiredWidth = Math.max(requiredWidth, bounds.right);
|
|
1755
|
+
requiredHeight = Math.max(requiredHeight, bounds.bottom);
|
|
1636
1756
|
});
|
|
1757
|
+
if (usesScrollableFitBounds) {
|
|
1758
|
+
if (this.originalImage) includeObjectBounds(this.originalImage, 0);
|
|
1759
|
+
this.canvas.getObjects().forEach((object) => {
|
|
1760
|
+
if (object && object.maskId) includeObjectBounds(object, padding);
|
|
1761
|
+
});
|
|
1762
|
+
const contentSize = this._getScrollableCanvasSize(
|
|
1763
|
+
Math.max(1, contentWidth),
|
|
1764
|
+
Math.max(1, contentHeight)
|
|
1765
|
+
);
|
|
1766
|
+
const newWidth2 = contentSize.hasHorizontal ? Math.max(currentWidth, contentSize.width) : contentSize.width;
|
|
1767
|
+
const newHeight2 = contentSize.hasVertical ? Math.max(currentHeight, contentSize.height) : contentSize.height;
|
|
1768
|
+
if (newWidth2 !== currentWidth || newHeight2 !== currentHeight) {
|
|
1769
|
+
this._setCanvasSizeInt(newWidth2, newHeight2);
|
|
1770
|
+
}
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1637
1773
|
let minWidth = 0;
|
|
1638
1774
|
let minHeight = 0;
|
|
1639
1775
|
if (this.containerElement) {
|
|
@@ -1651,16 +1787,60 @@
|
|
|
1651
1787
|
this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
|
|
1652
1788
|
}
|
|
1653
1789
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1790
|
+
_captureImageDisplayBounds() {
|
|
1791
|
+
if (!this.originalImage || !this.canvas) return null;
|
|
1792
|
+
this.originalImage.setCoords();
|
|
1793
|
+
const bounds = this.originalImage.getBoundingRect(true, true);
|
|
1794
|
+
const width = Number(bounds && bounds.width);
|
|
1795
|
+
const height = Number(bounds && bounds.height);
|
|
1796
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
|
|
1797
|
+
return {
|
|
1798
|
+
left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
|
|
1799
|
+
top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
|
|
1800
|
+
width,
|
|
1801
|
+
height
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
_restoreImageDisplayBounds(displayBounds) {
|
|
1805
|
+
if (!displayBounds || !this.originalImage || !this.canvas) return;
|
|
1806
|
+
const imageWidth = Number(this.originalImage.width);
|
|
1807
|
+
const imageHeight = Number(this.originalImage.height);
|
|
1808
|
+
if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
|
|
1809
|
+
const scaleX = Number(displayBounds.width) / imageWidth;
|
|
1810
|
+
const scaleY = Number(displayBounds.height) / imageHeight;
|
|
1811
|
+
if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
|
|
1812
|
+
const left = Number(displayBounds.left) || 0;
|
|
1813
|
+
const top = Number(displayBounds.top) || 0;
|
|
1814
|
+
const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
|
|
1815
|
+
const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
|
|
1816
|
+
const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
|
|
1817
|
+
const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
|
|
1818
|
+
const layoutMode = this._getImageLayoutMode();
|
|
1819
|
+
if (layoutMode === "fit" || layoutMode === "cover") {
|
|
1820
|
+
const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
|
|
1821
|
+
if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
|
|
1822
|
+
this._setCanvasSizeInt(contentSize.width, contentSize.height);
|
|
1823
|
+
}
|
|
1824
|
+
} else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
|
|
1825
|
+
this._setCanvasSizeInt(
|
|
1826
|
+
Math.max(currentCanvasWidth, requiredCanvasWidth),
|
|
1827
|
+
Math.max(currentCanvasHeight, requiredCanvasHeight)
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
this.originalImage.set({
|
|
1831
|
+
originX: "left",
|
|
1832
|
+
originY: "top",
|
|
1833
|
+
left,
|
|
1834
|
+
top,
|
|
1835
|
+
scaleX,
|
|
1836
|
+
scaleY
|
|
1837
|
+
});
|
|
1838
|
+
this.originalImage.setCoords();
|
|
1839
|
+
this.baseImageScale = scaleX;
|
|
1840
|
+
this.currentScale = 1;
|
|
1841
|
+
this.currentRotation = Number(this.originalImage.angle) || 0;
|
|
1842
|
+
this._updateInputs();
|
|
1843
|
+
this.canvas.renderAll();
|
|
1664
1844
|
}
|
|
1665
1845
|
/**
|
|
1666
1846
|
* Scales the original image by a given factor, with animation.
|
|
@@ -1675,7 +1855,14 @@
|
|
|
1675
1855
|
} catch (error) {
|
|
1676
1856
|
return Promise.reject(error);
|
|
1677
1857
|
}
|
|
1678
|
-
return this.animationQueue.add(
|
|
1858
|
+
return this.animationQueue.add(async () => {
|
|
1859
|
+
const operationToken = this._beginBusyOperation("scaleImage");
|
|
1860
|
+
try {
|
|
1861
|
+
await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
|
|
1862
|
+
} finally {
|
|
1863
|
+
this._endBusyOperation(operationToken);
|
|
1864
|
+
}
|
|
1865
|
+
}).finally(() => {
|
|
1679
1866
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
1680
1867
|
});
|
|
1681
1868
|
}
|
|
@@ -1718,7 +1905,7 @@
|
|
|
1718
1905
|
if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
|
|
1719
1906
|
throw new Error(`${operationName} cannot run while crop mode is active`);
|
|
1720
1907
|
}
|
|
1721
|
-
if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
|
|
1908
|
+
if ((this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) && !isOwnInternalOperation) {
|
|
1722
1909
|
throw new Error(`${operationName} cannot run while an animation is running`);
|
|
1723
1910
|
}
|
|
1724
1911
|
if (this._isLoading && !isOwnInternalOperation) {
|
|
@@ -1831,7 +2018,7 @@
|
|
|
1831
2018
|
if (object.maskId) this._syncMaskLabel(object);
|
|
1832
2019
|
});
|
|
1833
2020
|
this._updateInputs();
|
|
1834
|
-
if (saveHistory) this.saveState();
|
|
2021
|
+
if (saveHistory) this.saveState(options);
|
|
1835
2022
|
} finally {
|
|
1836
2023
|
if (didStartAnimation) {
|
|
1837
2024
|
this.isAnimating = false;
|
|
@@ -1853,7 +2040,14 @@
|
|
|
1853
2040
|
} catch (error) {
|
|
1854
2041
|
return Promise.reject(error);
|
|
1855
2042
|
}
|
|
1856
|
-
return this.animationQueue.add(
|
|
2043
|
+
return this.animationQueue.add(async () => {
|
|
2044
|
+
const operationToken = this._beginBusyOperation("rotateImage");
|
|
2045
|
+
try {
|
|
2046
|
+
await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
|
|
2047
|
+
} finally {
|
|
2048
|
+
this._endBusyOperation(operationToken);
|
|
2049
|
+
}
|
|
2050
|
+
}).finally(() => {
|
|
1857
2051
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
1858
2052
|
});
|
|
1859
2053
|
}
|
|
@@ -1896,7 +2090,7 @@
|
|
|
1896
2090
|
if (object.maskId) this._syncMaskLabel(object);
|
|
1897
2091
|
});
|
|
1898
2092
|
this._updateInputs();
|
|
1899
|
-
if (saveHistory) this.saveState();
|
|
2093
|
+
if (saveHistory) this.saveState(options);
|
|
1900
2094
|
didCompleteRotation = true;
|
|
1901
2095
|
} finally {
|
|
1902
2096
|
if (!didCompleteRotation && !this._disposed && image) {
|
|
@@ -1923,19 +2117,22 @@
|
|
|
1923
2117
|
return Promise.reject(error);
|
|
1924
2118
|
}
|
|
1925
2119
|
return this.animationQueue.add(async () => {
|
|
2120
|
+
const operationToken = this._beginBusyOperation("resetImageTransform");
|
|
1926
2121
|
const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
|
|
1927
2122
|
try {
|
|
1928
|
-
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1929
|
-
await this._rotateImageImpl(0, { saveHistory: false });
|
|
2123
|
+
await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
|
|
2124
|
+
await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
|
|
1930
2125
|
const after = this._captureCanvasStateOrThrow("resetImageTransform");
|
|
1931
2126
|
this._pushStateTransition(before, after);
|
|
1932
2127
|
} catch (error) {
|
|
1933
2128
|
try {
|
|
1934
|
-
await this.loadFromState(before);
|
|
2129
|
+
await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
|
|
1935
2130
|
} catch (restoreError) {
|
|
1936
2131
|
this._reportError("resetImageTransform rollback failed", restoreError);
|
|
1937
2132
|
}
|
|
1938
2133
|
throw error;
|
|
2134
|
+
} finally {
|
|
2135
|
+
this._endBusyOperation(operationToken);
|
|
1939
2136
|
}
|
|
1940
2137
|
}).finally(() => {
|
|
1941
2138
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
@@ -1960,8 +2157,13 @@
|
|
|
1960
2157
|
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
1961
2158
|
* @public
|
|
1962
2159
|
*/
|
|
1963
|
-
loadFromState(serializedState) {
|
|
2160
|
+
loadFromState(serializedState, options = {}) {
|
|
1964
2161
|
if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
|
|
2162
|
+
try {
|
|
2163
|
+
this._assertIdleForOperation("loadFromState", options);
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
return Promise.reject(error);
|
|
2166
|
+
}
|
|
1965
2167
|
if (this._cropMode || this._cropRect) {
|
|
1966
2168
|
this._removeCropRect();
|
|
1967
2169
|
this._restoreCropObjectState();
|
|
@@ -2118,22 +2320,29 @@
|
|
|
2118
2320
|
* @returns {void}
|
|
2119
2321
|
* @public
|
|
2120
2322
|
*/
|
|
2121
|
-
saveState() {
|
|
2323
|
+
saveState(options = {}) {
|
|
2122
2324
|
if (!this.canvas) return;
|
|
2325
|
+
try {
|
|
2326
|
+
this._assertIdleForOperation("saveState", options);
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
this._reportError("saveState blocked", error);
|
|
2329
|
+
this._updateUI();
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2123
2332
|
try {
|
|
2124
2333
|
const after = this._captureCanvasStateOrThrow("saveState");
|
|
2125
2334
|
const before = this._lastSnapshot || after;
|
|
2126
2335
|
if (after === before) return;
|
|
2127
2336
|
let executedOnce = false;
|
|
2128
2337
|
const command = new Command(
|
|
2129
|
-
() => {
|
|
2338
|
+
(commandOptions = {}) => {
|
|
2130
2339
|
if (executedOnce) {
|
|
2131
|
-
return this.loadFromState(after);
|
|
2340
|
+
return this.loadFromState(after, commandOptions);
|
|
2132
2341
|
}
|
|
2133
2342
|
executedOnce = true;
|
|
2134
2343
|
return void 0;
|
|
2135
2344
|
},
|
|
2136
|
-
() => this.loadFromState(before)
|
|
2345
|
+
(commandOptions = {}) => this.loadFromState(before, commandOptions)
|
|
2137
2346
|
);
|
|
2138
2347
|
this.historyManager.execute(command);
|
|
2139
2348
|
this._lastSnapshot = after;
|
|
@@ -2162,8 +2371,8 @@
|
|
|
2162
2371
|
if (before === after) return;
|
|
2163
2372
|
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
2164
2373
|
const command = new Command(
|
|
2165
|
-
() => this.loadFromState(after),
|
|
2166
|
-
() => this.loadFromState(before)
|
|
2374
|
+
(commandOptions = {}) => this.loadFromState(after, commandOptions),
|
|
2375
|
+
(commandOptions = {}) => this.loadFromState(before, commandOptions)
|
|
2167
2376
|
);
|
|
2168
2377
|
this.historyManager.push(command);
|
|
2169
2378
|
this._lastSnapshot = after;
|
|
@@ -2176,8 +2385,16 @@
|
|
|
2176
2385
|
* @public
|
|
2177
2386
|
*/
|
|
2178
2387
|
undo() {
|
|
2179
|
-
|
|
2388
|
+
try {
|
|
2389
|
+
this._assertIdleForOperation("undo");
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
return Promise.reject(error);
|
|
2392
|
+
}
|
|
2393
|
+
const operationToken = this._beginBusyOperation("undo");
|
|
2394
|
+
return this.historyManager.undo(this._withInternalOperationOptions(operationToken)).then(() => {
|
|
2180
2395
|
this._updateUI();
|
|
2396
|
+
}).finally(() => {
|
|
2397
|
+
this._endBusyOperation(operationToken);
|
|
2181
2398
|
}).catch((error) => {
|
|
2182
2399
|
this._reportError("undo failed", error);
|
|
2183
2400
|
throw error;
|
|
@@ -2190,8 +2407,16 @@
|
|
|
2190
2407
|
* @public
|
|
2191
2408
|
*/
|
|
2192
2409
|
redo() {
|
|
2193
|
-
|
|
2410
|
+
try {
|
|
2411
|
+
this._assertIdleForOperation("redo");
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
return Promise.reject(error);
|
|
2414
|
+
}
|
|
2415
|
+
const operationToken = this._beginBusyOperation("redo");
|
|
2416
|
+
return this.historyManager.redo(this._withInternalOperationOptions(operationToken)).then(() => {
|
|
2194
2417
|
this._updateUI();
|
|
2418
|
+
}).finally(() => {
|
|
2419
|
+
this._endBusyOperation(operationToken);
|
|
2195
2420
|
}).catch((error) => {
|
|
2196
2421
|
this._reportError("redo failed", error);
|
|
2197
2422
|
throw error;
|
|
@@ -2307,18 +2532,43 @@
|
|
|
2307
2532
|
}
|
|
2308
2533
|
return value != null ? value : fallback;
|
|
2309
2534
|
};
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2535
|
+
const rejectInvalidMask = (message, error = null) => {
|
|
2536
|
+
this._reportWarning(`createMask: ${message}`, error);
|
|
2537
|
+
return null;
|
|
2538
|
+
};
|
|
2539
|
+
const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
|
|
2540
|
+
const resolvedValue = resolveValue(value, fallback, axis);
|
|
2541
|
+
const numericValue = Number(resolvedValue);
|
|
2542
|
+
if (!Number.isFinite(numericValue)) {
|
|
2543
|
+
throw new Error(`${fieldName} must be a finite number`);
|
|
2544
|
+
}
|
|
2545
|
+
if (constraints.positive && numericValue <= 0) {
|
|
2546
|
+
throw new Error(`${fieldName} must be greater than 0`);
|
|
2547
|
+
}
|
|
2548
|
+
if (constraints.nonNegative && numericValue < 0) {
|
|
2549
|
+
throw new Error(`${fieldName} must be 0 or greater`);
|
|
2550
|
+
}
|
|
2551
|
+
return numericValue;
|
|
2552
|
+
};
|
|
2553
|
+
try {
|
|
2554
|
+
maskConfig.gap = resolveNumber(maskConfig.gap, 5, "width", "gap", { nonNegative: true });
|
|
2555
|
+
maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, "width", "width", { positive: true });
|
|
2556
|
+
maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, "height", "height", { positive: true });
|
|
2557
|
+
maskConfig.angle = resolveNumber(maskConfig.angle, 0, "width", "angle");
|
|
2558
|
+
maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, "width", "alpha")));
|
|
2559
|
+
if (maskConfig.left === void 0 && this._lastMask) {
|
|
2560
|
+
const previousMask = this._lastMask;
|
|
2561
|
+
if (typeof previousMask.setCoords === "function") previousMask.setCoords();
|
|
2562
|
+
const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
|
|
2563
|
+
left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
|
|
2564
|
+
top = Math.round(previousBounds.top ?? firstOffset);
|
|
2565
|
+
} else {
|
|
2566
|
+
left = resolveNumber(maskConfig.left, firstOffset, "width", "left");
|
|
2567
|
+
top = resolveNumber(maskConfig.top, firstOffset, "height", "top");
|
|
2568
|
+
}
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
return rejectInvalidMask("invalid numeric configuration", error);
|
|
2319
2571
|
}
|
|
2320
|
-
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
|
|
2321
|
-
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
|
|
2322
2572
|
maskConfig.left = left;
|
|
2323
2573
|
maskConfig.top = top;
|
|
2324
2574
|
let mask;
|
|
@@ -2327,10 +2577,15 @@
|
|
|
2327
2577
|
} else {
|
|
2328
2578
|
switch (shapeType) {
|
|
2329
2579
|
case "circle":
|
|
2580
|
+
try {
|
|
2581
|
+
maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min", "radius", { positive: true });
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
return rejectInvalidMask("invalid circle radius", error);
|
|
2584
|
+
}
|
|
2330
2585
|
mask = new fabric.Circle({
|
|
2331
2586
|
left,
|
|
2332
2587
|
top,
|
|
2333
|
-
radius:
|
|
2588
|
+
radius: maskConfig.radius,
|
|
2334
2589
|
fill: maskConfig.color,
|
|
2335
2590
|
opacity: maskConfig.alpha,
|
|
2336
2591
|
angle: maskConfig.angle,
|
|
@@ -2338,11 +2593,17 @@
|
|
|
2338
2593
|
});
|
|
2339
2594
|
break;
|
|
2340
2595
|
case "ellipse":
|
|
2596
|
+
try {
|
|
2597
|
+
maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, "width", "rx", { positive: true });
|
|
2598
|
+
maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, "height", "ry", { positive: true });
|
|
2599
|
+
} catch (error) {
|
|
2600
|
+
return rejectInvalidMask("invalid ellipse radius", error);
|
|
2601
|
+
}
|
|
2341
2602
|
mask = new fabric.Ellipse({
|
|
2342
2603
|
left,
|
|
2343
2604
|
top,
|
|
2344
|
-
rx:
|
|
2345
|
-
ry:
|
|
2605
|
+
rx: maskConfig.rx,
|
|
2606
|
+
ry: maskConfig.ry,
|
|
2346
2607
|
fill: maskConfig.color,
|
|
2347
2608
|
opacity: maskConfig.alpha,
|
|
2348
2609
|
angle: maskConfig.angle,
|
|
@@ -2351,8 +2612,20 @@
|
|
|
2351
2612
|
break;
|
|
2352
2613
|
case "polygon": {
|
|
2353
2614
|
let polygonPoints = maskConfig.points || [];
|
|
2354
|
-
if (Array.isArray(polygonPoints)
|
|
2355
|
-
|
|
2615
|
+
if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
|
|
2616
|
+
return rejectInvalidMask("polygon masks require at least three points");
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
polygonPoints = polygonPoints.map((point) => {
|
|
2620
|
+
const x = Number(Array.isArray(point) ? point[0] : point.x);
|
|
2621
|
+
const y = Number(Array.isArray(point) ? point[1] : point.y);
|
|
2622
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
2623
|
+
throw new Error("polygon point coordinates must be finite numbers");
|
|
2624
|
+
}
|
|
2625
|
+
return { x, y };
|
|
2626
|
+
});
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
return rejectInvalidMask("invalid polygon points", error);
|
|
2356
2629
|
}
|
|
2357
2630
|
mask = new fabric.Polygon(polygonPoints, {
|
|
2358
2631
|
left,
|
|
@@ -2366,11 +2639,17 @@
|
|
|
2366
2639
|
}
|
|
2367
2640
|
case "rect":
|
|
2368
2641
|
default:
|
|
2642
|
+
try {
|
|
2643
|
+
if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, "width", "rx", { nonNegative: true });
|
|
2644
|
+
if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, "height", "ry", { nonNegative: true });
|
|
2645
|
+
} catch (error) {
|
|
2646
|
+
return rejectInvalidMask("invalid rectangle corner radius", error);
|
|
2647
|
+
}
|
|
2369
2648
|
mask = new fabric.Rect({
|
|
2370
2649
|
left,
|
|
2371
2650
|
top,
|
|
2372
|
-
width:
|
|
2373
|
-
height:
|
|
2651
|
+
width: maskConfig.width,
|
|
2652
|
+
height: maskConfig.height,
|
|
2374
2653
|
fill: maskConfig.color,
|
|
2375
2654
|
opacity: maskConfig.alpha,
|
|
2376
2655
|
angle: maskConfig.angle,
|
|
@@ -2408,10 +2687,10 @@
|
|
|
2408
2687
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
2409
2688
|
});
|
|
2410
2689
|
this._rebindMaskEvents(mask);
|
|
2411
|
-
this.
|
|
2690
|
+
this._expandCanvasToFitObjects([mask]);
|
|
2412
2691
|
this._lastMaskInitialLeft = left;
|
|
2413
2692
|
this._lastMaskInitialTop = top;
|
|
2414
|
-
this._lastMaskInitialWidth =
|
|
2693
|
+
this._lastMaskInitialWidth = maskConfig.width;
|
|
2415
2694
|
const maskId = ++this.maskCounter;
|
|
2416
2695
|
mask.set({
|
|
2417
2696
|
maskId,
|
|
@@ -2839,6 +3118,7 @@
|
|
|
2839
3118
|
this._assertIdleForOperation("mergeMasks");
|
|
2840
3119
|
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
2841
3120
|
if (!masks.length) return;
|
|
3121
|
+
const beforeImageDisplayBounds = this._captureImageDisplayBounds();
|
|
2842
3122
|
const beforeJson = this._serializeCanvasState();
|
|
2843
3123
|
const operationToken = this._beginBusyOperation("mergeMasks");
|
|
2844
3124
|
this.canvas.discardActiveObject();
|
|
@@ -2857,12 +3137,13 @@
|
|
|
2857
3137
|
preserveScroll: true,
|
|
2858
3138
|
resetMaskCounter: false
|
|
2859
3139
|
}));
|
|
3140
|
+
this._restoreImageDisplayBounds(beforeImageDisplayBounds);
|
|
2860
3141
|
const afterJson = this._serializeCanvasState();
|
|
2861
3142
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2862
3143
|
} catch (error) {
|
|
2863
3144
|
this._reportError("merge error", error);
|
|
2864
3145
|
try {
|
|
2865
|
-
await this.loadFromState(beforeJson);
|
|
3146
|
+
await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
|
|
2866
3147
|
} catch (restoreError) {
|
|
2867
3148
|
this._reportError("mergeMasks rollback failed", restoreError);
|
|
2868
3149
|
}
|
|
@@ -2919,24 +3200,65 @@
|
|
|
2919
3200
|
*/
|
|
2920
3201
|
async exportImageBase64(options = {}) {
|
|
2921
3202
|
if (!this.originalImage) throw new Error("No image loaded");
|
|
3203
|
+
options = options || {};
|
|
2922
3204
|
this._assertIdleForOperation("exportImageBase64", options);
|
|
3205
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
3206
|
+
const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageBase64");
|
|
2923
3207
|
const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
2924
3208
|
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
2925
3209
|
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
2926
3210
|
const format = this._normalizeImageFormat(options.fileType || options.format);
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
3211
|
+
try {
|
|
3212
|
+
if (!exportImageArea) {
|
|
3213
|
+
const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
|
|
3214
|
+
const editableMasks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
3215
|
+
const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
|
|
3216
|
+
const maskStyleBackups2 = this._captureMaskExportBackups(editableMasks);
|
|
3217
|
+
const labelBackups2 = this._captureMaskLabelBackups(editableMasks);
|
|
3218
|
+
const activeObjectBackup2 = this._captureActiveObjectBackup();
|
|
3219
|
+
try {
|
|
3220
|
+
masks2.forEach((mask) => {
|
|
3221
|
+
mask.set({ visible: false });
|
|
3222
|
+
});
|
|
3223
|
+
this.canvas.discardActiveObject();
|
|
3224
|
+
this.canvas.renderAll();
|
|
3225
|
+
this.originalImage.setCoords();
|
|
3226
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
3227
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
3228
|
+
return await this._exportCanvasRegionToDataURL({
|
|
3229
|
+
...exportRegion,
|
|
3230
|
+
multiplier,
|
|
3231
|
+
quality,
|
|
3232
|
+
format,
|
|
3233
|
+
sealPartialEdges: this._getPartialExportEdges(imageBounds)
|
|
3234
|
+
});
|
|
3235
|
+
} finally {
|
|
3236
|
+
maskVisibilityBackups.forEach((backup) => {
|
|
3237
|
+
try {
|
|
3238
|
+
backup.object.set({ visible: backup.visible });
|
|
3239
|
+
} catch (error) {
|
|
3240
|
+
void error;
|
|
3241
|
+
}
|
|
3242
|
+
});
|
|
3243
|
+
this._restoreMaskExportBackups(maskStyleBackups2);
|
|
3244
|
+
this._restoreMaskLabelBackups(labelBackups2);
|
|
3245
|
+
this._restoreActiveObjectBackup(activeObjectBackup2);
|
|
3246
|
+
this.canvas.renderAll();
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
3250
|
+
const maskStyleBackups = this._captureMaskExportBackups(masks);
|
|
3251
|
+
const labelBackups = this._captureMaskLabelBackups(masks);
|
|
3252
|
+
const activeObjectBackup = this._captureActiveObjectBackup();
|
|
2934
3253
|
try {
|
|
2935
|
-
|
|
2936
|
-
mask.set({ visible: false });
|
|
2937
|
-
});
|
|
3254
|
+
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
2938
3255
|
this.canvas.discardActiveObject();
|
|
2939
3256
|
this.canvas.renderAll();
|
|
3257
|
+
masks.forEach((mask) => {
|
|
3258
|
+
mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
|
|
3259
|
+
mask.setCoords();
|
|
3260
|
+
});
|
|
3261
|
+
this.canvas.renderAll();
|
|
2940
3262
|
this.originalImage.setCoords();
|
|
2941
3263
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2942
3264
|
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
@@ -2948,47 +3270,13 @@
|
|
|
2948
3270
|
sealPartialEdges: this._getPartialExportEdges(imageBounds)
|
|
2949
3271
|
});
|
|
2950
3272
|
} finally {
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
} catch (error) {
|
|
2955
|
-
void error;
|
|
2956
|
-
}
|
|
2957
|
-
});
|
|
2958
|
-
this._restoreMaskExportBackups(maskStyleBackups2);
|
|
2959
|
-
this._restoreMaskLabelBackups(labelBackups2);
|
|
2960
|
-
this._restoreActiveObjectBackup(activeObjectBackup2);
|
|
3273
|
+
this._restoreMaskExportBackups(maskStyleBackups);
|
|
3274
|
+
this._restoreMaskLabelBackups(labelBackups);
|
|
3275
|
+
this._restoreActiveObjectBackup(activeObjectBackup);
|
|
2961
3276
|
this.canvas.renderAll();
|
|
2962
3277
|
}
|
|
2963
|
-
}
|
|
2964
|
-
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
2965
|
-
const maskStyleBackups = this._captureMaskExportBackups(masks);
|
|
2966
|
-
const labelBackups = this._captureMaskLabelBackups(masks);
|
|
2967
|
-
const activeObjectBackup = this._captureActiveObjectBackup();
|
|
2968
|
-
try {
|
|
2969
|
-
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
2970
|
-
this.canvas.discardActiveObject();
|
|
2971
|
-
this.canvas.renderAll();
|
|
2972
|
-
masks.forEach((mask) => {
|
|
2973
|
-
mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
|
|
2974
|
-
mask.setCoords();
|
|
2975
|
-
});
|
|
2976
|
-
this.canvas.renderAll();
|
|
2977
|
-
this.originalImage.setCoords();
|
|
2978
|
-
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2979
|
-
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
2980
|
-
return await this._exportCanvasRegionToDataURL({
|
|
2981
|
-
...exportRegion,
|
|
2982
|
-
multiplier,
|
|
2983
|
-
quality,
|
|
2984
|
-
format,
|
|
2985
|
-
sealPartialEdges: this._getPartialExportEdges(imageBounds)
|
|
2986
|
-
});
|
|
2987
3278
|
} finally {
|
|
2988
|
-
this.
|
|
2989
|
-
this._restoreMaskLabelBackups(labelBackups);
|
|
2990
|
-
this._restoreActiveObjectBackup(activeObjectBackup);
|
|
2991
|
-
this.canvas.renderAll();
|
|
3279
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
2992
3280
|
}
|
|
2993
3281
|
}
|
|
2994
3282
|
/**
|
|
@@ -3021,7 +3309,10 @@
|
|
|
3021
3309
|
*/
|
|
3022
3310
|
async exportImageFile(options = {}) {
|
|
3023
3311
|
if (!this.originalImage) throw new Error("No image loaded");
|
|
3024
|
-
|
|
3312
|
+
options = options || {};
|
|
3313
|
+
this._assertIdleForOperation("exportImageFile", options);
|
|
3314
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
3315
|
+
const operationToken = isNestedOperation ? this._getInternalOperationToken(options) : this._beginBusyOperation("exportImageFile");
|
|
3025
3316
|
const {
|
|
3026
3317
|
mergeMask = true,
|
|
3027
3318
|
fileType = "jpeg",
|
|
@@ -3031,48 +3322,52 @@
|
|
|
3031
3322
|
} = options;
|
|
3032
3323
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
3033
3324
|
const normalizedQuality = this._normalizeQuality(quality);
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3325
|
+
try {
|
|
3326
|
+
let imageBase64;
|
|
3327
|
+
if (mergeMask) {
|
|
3328
|
+
imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
|
|
3329
|
+
exportImageArea: true,
|
|
3330
|
+
multiplier,
|
|
3331
|
+
quality: normalizedQuality,
|
|
3332
|
+
fileType: safeFileType
|
|
3333
|
+
}));
|
|
3334
|
+
} else {
|
|
3335
|
+
imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
|
|
3336
|
+
exportImageArea: false,
|
|
3337
|
+
multiplier,
|
|
3338
|
+
quality: normalizedQuality,
|
|
3339
|
+
fileType: safeFileType
|
|
3340
|
+
}));
|
|
3341
|
+
}
|
|
3342
|
+
let imageDataUrl = imageBase64;
|
|
3343
|
+
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
3344
|
+
imageDataUrl = await new Promise((resolve, reject) => {
|
|
3345
|
+
const imageElement = new window.Image();
|
|
3346
|
+
imageElement.crossOrigin = "Anonymous";
|
|
3347
|
+
imageElement.onload = () => {
|
|
3348
|
+
try {
|
|
3349
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
3350
|
+
offscreenCanvas.width = imageElement.width;
|
|
3351
|
+
offscreenCanvas.height = imageElement.height;
|
|
3352
|
+
const context = offscreenCanvas.getContext("2d");
|
|
3353
|
+
if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
|
|
3354
|
+
context.drawImage(imageElement, 0, 0);
|
|
3355
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
|
|
3356
|
+
resolve(convertedDataUrl);
|
|
3357
|
+
} catch (error) {
|
|
3358
|
+
reject(error);
|
|
3359
|
+
}
|
|
3360
|
+
};
|
|
3361
|
+
imageElement.onerror = reject;
|
|
3362
|
+
imageElement.src = imageBase64;
|
|
3363
|
+
});
|
|
3364
|
+
}
|
|
3365
|
+
const bytes = this._decodeDataUrlPayload(imageDataUrl);
|
|
3366
|
+
const mime = `image/${safeFileType}`;
|
|
3367
|
+
return new File([bytes], fileName, { type: mime });
|
|
3368
|
+
} finally {
|
|
3369
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
3072
3370
|
}
|
|
3073
|
-
const bytes = this._decodeBase64Payload(imageDataUrl.split(",")[1]);
|
|
3074
|
-
const mime = `image/${safeFileType}`;
|
|
3075
|
-
return new File([bytes], fileName, { type: mime });
|
|
3076
3371
|
}
|
|
3077
3372
|
_clearMaskPlacementMemory() {
|
|
3078
3373
|
this._lastMask = null;
|
|
@@ -3080,7 +3375,7 @@
|
|
|
3080
3375
|
this._lastMaskInitialTop = null;
|
|
3081
3376
|
this._lastMaskInitialWidth = null;
|
|
3082
3377
|
}
|
|
3083
|
-
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
3378
|
+
async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
|
|
3084
3379
|
this._reportError(message, error);
|
|
3085
3380
|
if (this._cropRect && this.canvas) this._removeCropRect();
|
|
3086
3381
|
this._cropRect = null;
|
|
@@ -3091,7 +3386,7 @@
|
|
|
3091
3386
|
this._prevSelectionSetting = void 0;
|
|
3092
3387
|
if (beforeJson) {
|
|
3093
3388
|
try {
|
|
3094
|
-
await this.loadFromState(beforeJson);
|
|
3389
|
+
await this.loadFromState(beforeJson, options);
|
|
3095
3390
|
} catch (restoreError) {
|
|
3096
3391
|
this._reportError("applyCrop: rollback failed", restoreError);
|
|
3097
3392
|
}
|
|
@@ -3137,6 +3432,17 @@
|
|
|
3137
3432
|
this._cropRect = null;
|
|
3138
3433
|
this._cropHandlers = [];
|
|
3139
3434
|
}
|
|
3435
|
+
_getCropRectContentBounds(cropRect) {
|
|
3436
|
+
if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
|
|
3437
|
+
const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
|
|
3438
|
+
const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
|
|
3439
|
+
return {
|
|
3440
|
+
left: Number(cropRect.left) || 0,
|
|
3441
|
+
top: Number(cropRect.top) || 0,
|
|
3442
|
+
width,
|
|
3443
|
+
height
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3140
3446
|
/**
|
|
3141
3447
|
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
3142
3448
|
*
|
|
@@ -3160,14 +3466,19 @@
|
|
|
3160
3466
|
const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
|
|
3161
3467
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
3162
3468
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
3163
|
-
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width
|
|
3164
|
-
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height
|
|
3469
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
|
|
3470
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
|
|
3165
3471
|
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
3166
3472
|
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
3167
3473
|
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
3168
3474
|
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
3169
3475
|
const width = minCropWidth;
|
|
3170
3476
|
const height = minCropHeight;
|
|
3477
|
+
const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
|
|
3478
|
+
if (requestedCropRotation && !this._cropRotationWarningEmitted) {
|
|
3479
|
+
this._cropRotationWarningEmitted = true;
|
|
3480
|
+
this._reportWarning("crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported");
|
|
3481
|
+
}
|
|
3171
3482
|
const cropRect = new fabric.Rect({
|
|
3172
3483
|
left,
|
|
3173
3484
|
top,
|
|
@@ -3179,8 +3490,8 @@
|
|
|
3179
3490
|
strokeWidth: 1,
|
|
3180
3491
|
strokeUniform: true,
|
|
3181
3492
|
selectable: true,
|
|
3182
|
-
hasRotatingPoint:
|
|
3183
|
-
lockRotation:
|
|
3493
|
+
hasRotatingPoint: false,
|
|
3494
|
+
lockRotation: true,
|
|
3184
3495
|
cornerSize: 8,
|
|
3185
3496
|
objectCaching: false,
|
|
3186
3497
|
originX: "left",
|
|
@@ -3217,7 +3528,7 @@
|
|
|
3217
3528
|
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
3218
3529
|
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
3219
3530
|
cropRect.setCoords();
|
|
3220
|
-
const cropBounds =
|
|
3531
|
+
const cropBounds = this._getCropRectContentBounds(cropRect);
|
|
3221
3532
|
const imageLeft = Number(imageBounds.left) || 0;
|
|
3222
3533
|
const imageTop = Number(imageBounds.top) || 0;
|
|
3223
3534
|
const imageRight = imageLeft + (Number(imageBounds.width) || 0);
|
|
@@ -3291,94 +3602,100 @@
|
|
|
3291
3602
|
async applyCrop() {
|
|
3292
3603
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
3293
3604
|
this._assertIdleForOperation("applyCrop");
|
|
3294
|
-
this.
|
|
3295
|
-
const
|
|
3296
|
-
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
3297
|
-
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
3298
|
-
this._restoreCropObjectState();
|
|
3299
|
-
let beforeJson;
|
|
3300
|
-
try {
|
|
3301
|
-
beforeJson = this._serializeCanvasState();
|
|
3302
|
-
} catch (error) {
|
|
3303
|
-
this._reportError("applyCrop: failed to capture rollback state", error);
|
|
3304
|
-
beforeJson = null;
|
|
3305
|
-
}
|
|
3306
|
-
if (!beforeJson) {
|
|
3307
|
-
this.cancelCrop();
|
|
3308
|
-
return;
|
|
3309
|
-
}
|
|
3310
|
-
const preservedMasks = [];
|
|
3605
|
+
const operationToken = this._beginBusyOperation("applyCrop");
|
|
3606
|
+
const internalOptions = this._withInternalOperationOptions(operationToken);
|
|
3311
3607
|
try {
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
mask.set({ visible: true });
|
|
3324
|
-
preservedMasks.push(mask);
|
|
3325
|
-
}
|
|
3326
|
-
});
|
|
3327
|
-
this._clearMaskPlacementMemory();
|
|
3328
|
-
this.canvas.discardActiveObject();
|
|
3329
|
-
this.canvas.renderAll();
|
|
3608
|
+
this._cropRect.setCoords();
|
|
3609
|
+
const rectBounds = this._getCropRectContentBounds(this._cropRect);
|
|
3610
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
3611
|
+
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
3612
|
+
this._restoreCropObjectState();
|
|
3613
|
+
let beforeJson;
|
|
3614
|
+
try {
|
|
3615
|
+
beforeJson = this._serializeCanvasState();
|
|
3616
|
+
} catch (error) {
|
|
3617
|
+
this._reportError("applyCrop: failed to capture rollback state", error);
|
|
3618
|
+
beforeJson = null;
|
|
3330
3619
|
}
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3620
|
+
if (!beforeJson) {
|
|
3621
|
+
this.cancelCrop();
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
const preservedMasks = [];
|
|
3625
|
+
try {
|
|
3626
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
3627
|
+
if (masks && masks.length) {
|
|
3628
|
+
masks.forEach((mask) => {
|
|
3629
|
+
mask.setCoords();
|
|
3630
|
+
const maskBounds = mask.getBoundingRect(true, true);
|
|
3631
|
+
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;
|
|
3632
|
+
this._removeLabelForMask(mask);
|
|
3633
|
+
this._cleanupMaskEvents(mask);
|
|
3634
|
+
this.canvas.remove(mask);
|
|
3635
|
+
if (shouldPreserveMasks && intersectsCrop) {
|
|
3636
|
+
this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
|
|
3637
|
+
mask.set({ visible: true });
|
|
3638
|
+
preservedMasks.push(mask);
|
|
3639
|
+
}
|
|
3640
|
+
});
|
|
3641
|
+
this._clearMaskPlacementMemory();
|
|
3642
|
+
this.canvas.discardActiveObject();
|
|
3643
|
+
this.canvas.renderAll();
|
|
3644
|
+
}
|
|
3645
|
+
} catch (error) {
|
|
3646
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to prepare masks", error, internalOptions);
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
this._removeCropRect();
|
|
3650
|
+
this._cropMode = false;
|
|
3651
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
3652
|
+
this._prevSelectionSetting = void 0;
|
|
3653
|
+
let croppedBase64;
|
|
3654
|
+
try {
|
|
3655
|
+
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
3656
|
+
...cropRegion,
|
|
3657
|
+
multiplier: 1,
|
|
3658
|
+
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
3659
|
+
format: "jpeg"
|
|
3358
3660
|
});
|
|
3359
|
-
|
|
3360
|
-
this.
|
|
3361
|
-
|
|
3362
|
-
this.canvas.renderAll();
|
|
3661
|
+
} catch (error) {
|
|
3662
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error, internalOptions);
|
|
3663
|
+
return;
|
|
3363
3664
|
}
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3665
|
+
try {
|
|
3666
|
+
await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
|
|
3667
|
+
if (preservedMasks.length) {
|
|
3668
|
+
preservedMasks.forEach((mask) => {
|
|
3669
|
+
this._rebindMaskEvents(mask);
|
|
3670
|
+
this.canvas.add(mask);
|
|
3671
|
+
this.canvas.bringToFront(mask);
|
|
3672
|
+
});
|
|
3673
|
+
this._lastMask = preservedMasks[preservedMasks.length - 1];
|
|
3674
|
+
this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
|
|
3675
|
+
this._updateMaskList();
|
|
3676
|
+
this.canvas.renderAll();
|
|
3677
|
+
}
|
|
3678
|
+
} catch (error) {
|
|
3679
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error, internalOptions);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
let afterJson;
|
|
3683
|
+
try {
|
|
3684
|
+
afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
|
|
3685
|
+
} catch (error) {
|
|
3686
|
+
this._reportWarning("applyCrop: failed to serialize after state", error);
|
|
3687
|
+
afterJson = null;
|
|
3688
|
+
}
|
|
3689
|
+
try {
|
|
3690
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
3691
|
+
} catch (error) {
|
|
3692
|
+
this._reportWarning("applyCrop: failed to push history command", error);
|
|
3693
|
+
}
|
|
3694
|
+
this._updateUI();
|
|
3695
|
+
this.canvas.renderAll();
|
|
3696
|
+
} finally {
|
|
3697
|
+
this._endBusyOperation(operationToken);
|
|
3379
3698
|
}
|
|
3380
|
-
this._updateUI();
|
|
3381
|
-
this.canvas.renderAll();
|
|
3382
3699
|
}
|
|
3383
3700
|
/* ---------- Misc / UI ---------- */
|
|
3384
3701
|
/**
|
|
@@ -3798,11 +4115,11 @@
|
|
|
3798
4115
|
*
|
|
3799
4116
|
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
3800
4117
|
*/
|
|
3801
|
-
undo() {
|
|
4118
|
+
undo(options = {}) {
|
|
3802
4119
|
return this.enqueue(async () => {
|
|
3803
4120
|
if (this.currentIndex >= 0) {
|
|
3804
4121
|
const index = this.currentIndex;
|
|
3805
|
-
await this.history[index].undo();
|
|
4122
|
+
await this.history[index].undo(options);
|
|
3806
4123
|
this.currentIndex = index - 1;
|
|
3807
4124
|
}
|
|
3808
4125
|
});
|
|
@@ -3812,11 +4129,11 @@
|
|
|
3812
4129
|
*
|
|
3813
4130
|
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
3814
4131
|
*/
|
|
3815
|
-
redo() {
|
|
4132
|
+
redo(options = {}) {
|
|
3816
4133
|
return this.enqueue(async () => {
|
|
3817
4134
|
if (this.currentIndex < this.history.length - 1) {
|
|
3818
4135
|
const index = this.currentIndex + 1;
|
|
3819
|
-
await this.history[index].execute();
|
|
4136
|
+
await this.history[index].execute(options);
|
|
3820
4137
|
this.currentIndex = index;
|
|
3821
4138
|
}
|
|
3822
4139
|
});
|