@bensitu/image-editor 1.2.2 → 1.3.0
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 +7 -3
- package/dist/image-editor.esm.js +534 -207
- 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 +534 -207
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +534 -207
- 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 +21 -11
- package/package.json +1 -1
- package/src/image-editor.js +724 -287
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.
|
|
6
|
+
* @version 1.3.0
|
|
7
7
|
* @author Ben Situ
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
downsampleMaxWidth: 4e3,
|
|
75
75
|
downsampleMaxHeight: 3e3,
|
|
76
76
|
downsampleQuality: 0.92,
|
|
77
|
+
imageLoadTimeoutMs: 3e4,
|
|
77
78
|
exportMultiplier: 1,
|
|
78
79
|
exportImageAreaByDefault: true,
|
|
79
80
|
defaultMaskWidth: 50,
|
|
@@ -132,12 +133,16 @@
|
|
|
132
133
|
this._cropPrevEvented = null;
|
|
133
134
|
this._prevSelectionSetting = void 0;
|
|
134
135
|
this._containerOriginalOverflow = void 0;
|
|
136
|
+
this._scrollbarSizeCache = null;
|
|
135
137
|
this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
|
|
136
|
-
this.
|
|
138
|
+
this.animationQueue = new AnimationQueue();
|
|
137
139
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
138
140
|
}
|
|
139
141
|
/**
|
|
140
|
-
* @
|
|
142
|
+
* Backward-compatible alias for {@link ImageEditor#canvasElement}.
|
|
143
|
+
*
|
|
144
|
+
* @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
|
|
145
|
+
* @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
|
|
141
146
|
*/
|
|
142
147
|
get canvasEl() {
|
|
143
148
|
return this.canvasElement;
|
|
@@ -146,7 +151,10 @@
|
|
|
146
151
|
this.canvasElement = value;
|
|
147
152
|
}
|
|
148
153
|
/**
|
|
149
|
-
* @
|
|
154
|
+
* Backward-compatible alias for {@link ImageEditor#containerElement}.
|
|
155
|
+
*
|
|
156
|
+
* @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
|
|
157
|
+
* @returns {HTMLElement|null} The canvas viewport/container element.
|
|
150
158
|
*/
|
|
151
159
|
get containerEl() {
|
|
152
160
|
return this.containerElement;
|
|
@@ -155,7 +163,10 @@
|
|
|
155
163
|
this.containerElement = value;
|
|
156
164
|
}
|
|
157
165
|
/**
|
|
158
|
-
* @
|
|
166
|
+
* Backward-compatible alias for {@link ImageEditor#placeholderElement}.
|
|
167
|
+
*
|
|
168
|
+
* @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
|
|
169
|
+
* @returns {HTMLElement|null} The placeholder element shown before an image loads.
|
|
159
170
|
*/
|
|
160
171
|
get placeholderEl() {
|
|
161
172
|
return this.placeholderElement;
|
|
@@ -169,9 +180,10 @@
|
|
|
169
180
|
* Use this method to set up the editor UI before interacting with it.
|
|
170
181
|
*
|
|
171
182
|
* @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
|
|
172
|
-
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
173
|
-
* rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
174
|
-
* zoomInBtn, zoomOutBtn, resetBtn,
|
|
183
|
+
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
184
|
+
* rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
185
|
+
* mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
|
|
186
|
+
* uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
|
|
175
187
|
*
|
|
176
188
|
* @returns {void}
|
|
177
189
|
*
|
|
@@ -243,7 +255,9 @@
|
|
|
243
255
|
}
|
|
244
256
|
}
|
|
245
257
|
/**
|
|
246
|
-
*
|
|
258
|
+
* Initializes the Fabric canvas, viewport elements, and selection event handlers.
|
|
259
|
+
*
|
|
260
|
+
* @returns {void}
|
|
247
261
|
* @private
|
|
248
262
|
*/
|
|
249
263
|
_initCanvas() {
|
|
@@ -293,6 +307,13 @@
|
|
|
293
307
|
this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
|
|
294
308
|
this.canvasElement.style.display = "block";
|
|
295
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Records a history entry after Fabric finishes modifying one or more masks.
|
|
312
|
+
*
|
|
313
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
|
|
314
|
+
* @returns {void}
|
|
315
|
+
* @private
|
|
316
|
+
*/
|
|
296
317
|
_handleObjectModified(target) {
|
|
297
318
|
const masks = this._getModifiedMasks(target);
|
|
298
319
|
if (!masks.length)
|
|
@@ -301,10 +322,17 @@
|
|
|
301
322
|
if (typeof mask.setCoords === "function")
|
|
302
323
|
mask.setCoords();
|
|
303
324
|
this._syncMaskLabel(mask);
|
|
304
|
-
this._expandCanvasToFitObject(mask);
|
|
305
325
|
});
|
|
326
|
+
this._expandCanvasToFitObjects(masks);
|
|
306
327
|
this.saveState();
|
|
307
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Extracts editable mask objects from a Fabric modification target.
|
|
331
|
+
*
|
|
332
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
|
|
333
|
+
* @returns {Array<fabric.Object>} Modified mask objects.
|
|
334
|
+
* @private
|
|
335
|
+
*/
|
|
308
336
|
_getModifiedMasks(target) {
|
|
309
337
|
if (!target)
|
|
310
338
|
return [];
|
|
@@ -313,23 +341,33 @@
|
|
|
313
341
|
const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
|
|
314
342
|
return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
|
|
315
343
|
}
|
|
316
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Updates container overflow behavior for fit and cover image modes.
|
|
346
|
+
*
|
|
347
|
+
* @param {Object} [options={}] - Overflow update options.
|
|
348
|
+
* @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
|
|
349
|
+
* @returns {void}
|
|
350
|
+
* @private
|
|
351
|
+
*/
|
|
352
|
+
_syncContainerOverflow(options = {}) {
|
|
317
353
|
if (!this.containerElement || !this.containerElement.style)
|
|
318
354
|
return;
|
|
319
355
|
if (this._containerOriginalOverflow === void 0) {
|
|
320
356
|
this._containerOriginalOverflow = this.containerElement.style.overflow || "";
|
|
321
357
|
}
|
|
358
|
+
const shouldPreserveScroll = options.preserveScroll === true;
|
|
322
359
|
if (this.options.coverImageToCanvas) {
|
|
323
|
-
const shouldResetScroll = !this.isImageLoadedToCanvas;
|
|
324
360
|
this.containerElement.style.overflow = "scroll";
|
|
325
|
-
if (
|
|
361
|
+
if (!shouldPreserveScroll) {
|
|
326
362
|
this.containerElement.scrollLeft = 0;
|
|
327
363
|
this.containerElement.scrollTop = 0;
|
|
328
364
|
}
|
|
329
365
|
} else if (this.options.fitImageToCanvas) {
|
|
330
366
|
this.containerElement.style.overflow = "auto";
|
|
331
|
-
|
|
332
|
-
|
|
367
|
+
if (!shouldPreserveScroll) {
|
|
368
|
+
this.containerElement.scrollLeft = 0;
|
|
369
|
+
this.containerElement.scrollTop = 0;
|
|
370
|
+
}
|
|
333
371
|
} else {
|
|
334
372
|
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
335
373
|
}
|
|
@@ -388,12 +426,12 @@
|
|
|
388
426
|
});
|
|
389
427
|
this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
|
|
390
428
|
}
|
|
391
|
-
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* @param {
|
|
395
|
-
* @param {
|
|
396
|
-
* @param {
|
|
429
|
+
/**
|
|
430
|
+
* Binds a DOM event listener when the configured element exists and records it for disposal.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} key - Key in this.elements for the target DOM element.
|
|
433
|
+
* @param {string} eventName - DOM event name to listen for.
|
|
434
|
+
* @param {EventListener} handler - Event listener callback.
|
|
397
435
|
* @private
|
|
398
436
|
*/
|
|
399
437
|
_bindIfExists(key, eventName, handler) {
|
|
@@ -406,10 +444,10 @@
|
|
|
406
444
|
this._handlersByElementKey[key].push({ eventName, handler });
|
|
407
445
|
}
|
|
408
446
|
}
|
|
409
|
-
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* @param {File} file
|
|
447
|
+
/**
|
|
448
|
+
* Reads an image File as a data URL and loads it into the Fabric canvas.
|
|
449
|
+
*
|
|
450
|
+
* @param {File} file - Image file selected by the user.
|
|
413
451
|
* @private
|
|
414
452
|
*/
|
|
415
453
|
_loadImageFile(file) {
|
|
@@ -423,19 +461,42 @@
|
|
|
423
461
|
reader.readAsDataURL(file);
|
|
424
462
|
}
|
|
425
463
|
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
* @
|
|
464
|
+
* Warns when more than one mutually exclusive image layout mode is enabled.
|
|
465
|
+
*
|
|
466
|
+
* @returns {void}
|
|
467
|
+
* @private
|
|
468
|
+
*/
|
|
469
|
+
_warnOnImageLayoutOptionConflict() {
|
|
470
|
+
const activeModes = [
|
|
471
|
+
["fitImageToCanvas", this.options.fitImageToCanvas],
|
|
472
|
+
["coverImageToCanvas", this.options.coverImageToCanvas],
|
|
473
|
+
["expandCanvasToImage", this.options.expandCanvasToImage]
|
|
474
|
+
].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
|
|
475
|
+
if (activeModes.length <= 1)
|
|
476
|
+
return;
|
|
477
|
+
this._reportWarning(
|
|
478
|
+
`Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Loads a base64 data URL into the Fabric canvas as the base image.
|
|
483
|
+
*
|
|
484
|
+
* @async
|
|
485
|
+
* @param {string} imageBase64 - Image data URL beginning with `data:image/`.
|
|
486
|
+
* @param {LoadImageOptions} [options={}] - Optional load behavior.
|
|
487
|
+
* @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
|
|
488
|
+
* @public
|
|
429
489
|
*/
|
|
430
|
-
async loadImage(imageBase64) {
|
|
490
|
+
async loadImage(imageBase64, options = {}) {
|
|
431
491
|
if (!this._fabricLoaded)
|
|
432
492
|
return;
|
|
433
493
|
if (!this.canvas)
|
|
434
494
|
return;
|
|
435
495
|
if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
|
|
436
496
|
return;
|
|
497
|
+
this._warnOnImageLayoutOptionConflict();
|
|
437
498
|
this._setPlaceholderVisible(false);
|
|
438
|
-
this._syncContainerOverflow();
|
|
499
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
439
500
|
const imageElement = await this._createImageElement(imageBase64);
|
|
440
501
|
let loadSource = imageBase64;
|
|
441
502
|
if (this.options.downsampleOnLoad) {
|
|
@@ -466,8 +527,8 @@
|
|
|
466
527
|
const minWidth = viewport.width;
|
|
467
528
|
const minHeight = viewport.height;
|
|
468
529
|
if (this.options.fitImageToCanvas) {
|
|
469
|
-
const canvasWidth = Math.max(1,
|
|
470
|
-
const canvasHeight = Math.max(1,
|
|
530
|
+
const canvasWidth = Math.max(1, minWidth - 1);
|
|
531
|
+
const canvasHeight = Math.max(1, minHeight - 1);
|
|
471
532
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
472
533
|
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
473
534
|
fabricImage.set({ left: 0, top: 0 });
|
|
@@ -537,22 +598,34 @@
|
|
|
537
598
|
* Creates an HTMLImageElement from a given data URL.
|
|
538
599
|
*
|
|
539
600
|
* @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
|
|
601
|
+
* @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
|
|
540
602
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
541
603
|
* @private
|
|
542
604
|
*/
|
|
543
|
-
_createImageElement(dataUrl) {
|
|
605
|
+
_createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
544
606
|
return new Promise((resolve, reject) => {
|
|
545
607
|
const imageElement = new Image();
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
608
|
+
let isSettled = false;
|
|
609
|
+
const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
610
|
+
let timerId;
|
|
611
|
+
const settle = (callback) => {
|
|
612
|
+
if (isSettled)
|
|
613
|
+
return;
|
|
614
|
+
isSettled = true;
|
|
615
|
+
clearTimeout(timerId);
|
|
552
616
|
imageElement.onload = null;
|
|
553
617
|
imageElement.onerror = null;
|
|
554
|
-
|
|
618
|
+
callback();
|
|
555
619
|
};
|
|
620
|
+
timerId = setTimeout(() => {
|
|
621
|
+
settle(() => reject(new Error("Image load timed out")));
|
|
622
|
+
try {
|
|
623
|
+
imageElement.src = "";
|
|
624
|
+
} catch (error) {
|
|
625
|
+
}
|
|
626
|
+
}, safeTimeoutMs);
|
|
627
|
+
imageElement.onload = () => settle(() => resolve(imageElement));
|
|
628
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
556
629
|
imageElement.src = dataUrl;
|
|
557
630
|
});
|
|
558
631
|
}
|
|
@@ -571,6 +644,8 @@
|
|
|
571
644
|
offscreenCanvas.width = targetWidth;
|
|
572
645
|
offscreenCanvas.height = targetHeight;
|
|
573
646
|
const context = offscreenCanvas.getContext("2d");
|
|
647
|
+
if (!context)
|
|
648
|
+
throw new Error("2D canvas context is unavailable");
|
|
574
649
|
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
575
650
|
return offscreenCanvas.toDataURL("image/jpeg", quality);
|
|
576
651
|
}
|
|
@@ -578,20 +653,20 @@
|
|
|
578
653
|
* Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
|
|
579
654
|
* Also updates the corresponding style attributes.
|
|
580
655
|
*
|
|
581
|
-
* @param {number}
|
|
582
|
-
* @param {number}
|
|
656
|
+
* @param {number} width - Canvas width in pixels.
|
|
657
|
+
* @param {number} height - Canvas height in pixels.
|
|
583
658
|
* @private
|
|
584
659
|
*/
|
|
585
|
-
_setCanvasSizeInt(
|
|
586
|
-
const
|
|
587
|
-
const
|
|
588
|
-
this.canvas.setWidth(
|
|
589
|
-
this.canvas.setHeight(
|
|
660
|
+
_setCanvasSizeInt(width, height) {
|
|
661
|
+
const integerWidth = Math.max(1, Math.round(Number(width) || 1));
|
|
662
|
+
const integerHeight = Math.max(1, Math.round(Number(height) || 1));
|
|
663
|
+
this.canvas.setWidth(integerWidth);
|
|
664
|
+
this.canvas.setHeight(integerHeight);
|
|
590
665
|
if (typeof this.canvas.calcOffset === "function")
|
|
591
666
|
this.canvas.calcOffset();
|
|
592
667
|
if (this.canvasElement) {
|
|
593
|
-
this.canvasElement.style.width =
|
|
594
|
-
this.canvasElement.style.height =
|
|
668
|
+
this.canvasElement.style.width = integerWidth + "px";
|
|
669
|
+
this.canvasElement.style.height = integerHeight + "px";
|
|
595
670
|
this.canvasElement.style.maxWidth = "none";
|
|
596
671
|
}
|
|
597
672
|
}
|
|
@@ -615,11 +690,8 @@
|
|
|
615
690
|
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
616
691
|
};
|
|
617
692
|
}
|
|
618
|
-
const previousOverflow = this.containerElement.style.overflow;
|
|
619
|
-
this.containerElement.style.overflow = "hidden";
|
|
620
693
|
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
621
694
|
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
622
|
-
this.containerElement.style.overflow = previousOverflow;
|
|
623
695
|
return { width, height };
|
|
624
696
|
}
|
|
625
697
|
_hasFixedContainerScrollbars() {
|
|
@@ -640,6 +712,9 @@
|
|
|
640
712
|
return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
|
|
641
713
|
}
|
|
642
714
|
_getScrollbarSize() {
|
|
715
|
+
if (this._scrollbarSizeCache) {
|
|
716
|
+
return { ...this._scrollbarSizeCache };
|
|
717
|
+
}
|
|
643
718
|
if (typeof document === "undefined" || !document.createElement || !document.body) {
|
|
644
719
|
return { width: 0, height: 0 };
|
|
645
720
|
}
|
|
@@ -654,7 +729,8 @@
|
|
|
654
729
|
const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
|
655
730
|
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
656
731
|
document.body.removeChild(probe);
|
|
657
|
-
|
|
732
|
+
this._scrollbarSizeCache = { width, height };
|
|
733
|
+
return { ...this._scrollbarSizeCache };
|
|
658
734
|
}
|
|
659
735
|
_getScrollSafetyMargin() {
|
|
660
736
|
return 2;
|
|
@@ -821,6 +897,25 @@
|
|
|
821
897
|
if (typeof mask.setCoords === "function")
|
|
822
898
|
mask.setCoords();
|
|
823
899
|
}
|
|
900
|
+
/**
|
|
901
|
+
* Captures editor-owned runtime state that Fabric does not include in canvas JSON.
|
|
902
|
+
*
|
|
903
|
+
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
|
|
904
|
+
* @private
|
|
905
|
+
*/
|
|
906
|
+
_serializeEditorMetadata() {
|
|
907
|
+
const baseImageScale = Number(this.baseImageScale);
|
|
908
|
+
const currentScale = Number(this.currentScale);
|
|
909
|
+
const currentRotation = Number(this.currentRotation);
|
|
910
|
+
const maskCounter = Number(this.maskCounter);
|
|
911
|
+
return {
|
|
912
|
+
version: 1,
|
|
913
|
+
baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
|
|
914
|
+
currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
|
|
915
|
+
currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
|
|
916
|
+
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
|
|
917
|
+
};
|
|
918
|
+
}
|
|
824
919
|
_serializeCanvasState() {
|
|
825
920
|
if (!this.canvas)
|
|
826
921
|
return null;
|
|
@@ -829,15 +924,30 @@
|
|
|
829
924
|
if (Array.isArray(jsonObject.objects)) {
|
|
830
925
|
jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
|
|
831
926
|
}
|
|
927
|
+
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
832
928
|
return JSON.stringify(jsonObject);
|
|
833
929
|
});
|
|
834
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
|
|
933
|
+
*
|
|
934
|
+
* @param {number} quality - Requested image quality.
|
|
935
|
+
* @returns {number} A finite quality value between 0 and 1.
|
|
936
|
+
* @private
|
|
937
|
+
*/
|
|
835
938
|
_normalizeQuality(quality) {
|
|
836
939
|
const numericQuality = Number(quality);
|
|
837
940
|
if (!Number.isFinite(numericQuality))
|
|
838
941
|
return this.options.downsampleQuality ?? 0.92;
|
|
839
942
|
return Math.max(0, Math.min(1, numericQuality));
|
|
840
943
|
}
|
|
944
|
+
/**
|
|
945
|
+
* Normalizes public image format aliases to canvas export format names.
|
|
946
|
+
*
|
|
947
|
+
* @param {string} format - Requested image format or MIME type.
|
|
948
|
+
* @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
|
|
949
|
+
* @private
|
|
950
|
+
*/
|
|
841
951
|
_normalizeImageFormat(format) {
|
|
842
952
|
const typeMapping = {
|
|
843
953
|
"jpeg": "jpeg",
|
|
@@ -850,6 +960,15 @@
|
|
|
850
960
|
};
|
|
851
961
|
return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
|
|
852
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* Converts a bounding rectangle into a canvas-safe integer source region.
|
|
965
|
+
*
|
|
966
|
+
* @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
|
|
967
|
+
* @param {Object} [options={}] - Region rounding options.
|
|
968
|
+
* @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
|
|
969
|
+
* @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
|
|
970
|
+
* @private
|
|
971
|
+
*/
|
|
853
972
|
_getClampedCanvasRegion(bounds, options = {}) {
|
|
854
973
|
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
855
974
|
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
@@ -864,15 +983,49 @@
|
|
|
864
983
|
const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
|
|
865
984
|
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
866
985
|
return {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
986
|
+
sourceX,
|
|
987
|
+
sourceY,
|
|
988
|
+
sourceWidth: Math.max(1, endX - sourceX),
|
|
989
|
+
sourceHeight: Math.max(1, endY - sourceY)
|
|
871
990
|
};
|
|
872
991
|
}
|
|
992
|
+
/**
|
|
993
|
+
* Crops an image data URL to a source region using an offscreen canvas.
|
|
994
|
+
*
|
|
995
|
+
* @param {string} dataUrl - Source image data URL.
|
|
996
|
+
* @param {number} sourceX - Source region x coordinate.
|
|
997
|
+
* @param {number} sourceY - Source region y coordinate.
|
|
998
|
+
* @param {number} sourceWidth - Source region width.
|
|
999
|
+
* @param {number} sourceHeight - Source region height.
|
|
1000
|
+
* @param {number} multiplier - Export multiplier already applied to the source data URL.
|
|
1001
|
+
* @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
|
|
1002
|
+
* @param {number} [quality=0.92] - Output image quality for lossy formats.
|
|
1003
|
+
* @returns {Promise<string>} Resolves with the cropped image data URL.
|
|
1004
|
+
* @private
|
|
1005
|
+
*/
|
|
873
1006
|
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
|
|
874
1007
|
return new Promise((resolve, reject) => {
|
|
875
1008
|
const imageElement = new Image();
|
|
1009
|
+
let isSettled = false;
|
|
1010
|
+
const timeoutMs = Number(this.options.imageLoadTimeoutMs);
|
|
1011
|
+
const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
|
|
1012
|
+
let timerId;
|
|
1013
|
+
const settle = (callback) => {
|
|
1014
|
+
if (isSettled)
|
|
1015
|
+
return;
|
|
1016
|
+
isSettled = true;
|
|
1017
|
+
clearTimeout(timerId);
|
|
1018
|
+
imageElement.onload = null;
|
|
1019
|
+
imageElement.onerror = null;
|
|
1020
|
+
callback();
|
|
1021
|
+
};
|
|
1022
|
+
timerId = setTimeout(() => {
|
|
1023
|
+
settle(() => reject(new Error("Image crop load timed out")));
|
|
1024
|
+
try {
|
|
1025
|
+
imageElement.src = "";
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
}
|
|
1028
|
+
}, safeTimeoutMs);
|
|
876
1029
|
imageElement.onload = () => {
|
|
877
1030
|
try {
|
|
878
1031
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
@@ -884,24 +1037,40 @@
|
|
|
884
1037
|
offscreenCanvas.width = scaledSourceWidth;
|
|
885
1038
|
offscreenCanvas.height = scaledSourceHeight;
|
|
886
1039
|
const context = offscreenCanvas.getContext("2d");
|
|
1040
|
+
if (!context)
|
|
1041
|
+
throw new Error("2D canvas context is unavailable");
|
|
887
1042
|
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
888
|
-
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
1043
|
+
settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
|
|
889
1044
|
} catch (error) {
|
|
890
|
-
reject(error);
|
|
1045
|
+
settle(() => reject(error));
|
|
891
1046
|
}
|
|
892
1047
|
};
|
|
893
|
-
imageElement.onerror = reject;
|
|
1048
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
894
1049
|
imageElement.src = dataUrl;
|
|
895
1050
|
});
|
|
896
1051
|
}
|
|
897
|
-
|
|
1052
|
+
/**
|
|
1053
|
+
* Exports the whole Fabric canvas, then crops the requested source region from that export.
|
|
1054
|
+
*
|
|
1055
|
+
* @param {Object} region - Canvas source region and export options.
|
|
1056
|
+
* @param {number} region.sourceX - Source region x coordinate.
|
|
1057
|
+
* @param {number} region.sourceY - Source region y coordinate.
|
|
1058
|
+
* @param {number} region.sourceWidth - Source region width.
|
|
1059
|
+
* @param {number} region.sourceHeight - Source region height.
|
|
1060
|
+
* @param {number} [region.multiplier=1] - Export multiplier.
|
|
1061
|
+
* @param {number} [region.quality=0.92] - Output image quality for lossy formats.
|
|
1062
|
+
* @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
|
|
1063
|
+
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1064
|
+
* @private
|
|
1065
|
+
*/
|
|
1066
|
+
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
|
|
898
1067
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
899
1068
|
const fullDataUrl = this.canvas.toDataURL({
|
|
900
1069
|
format,
|
|
901
1070
|
quality,
|
|
902
1071
|
multiplier: safeMultiplier
|
|
903
1072
|
});
|
|
904
|
-
return this._cropDataUrl(fullDataUrl,
|
|
1073
|
+
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
905
1074
|
}
|
|
906
1075
|
/**
|
|
907
1076
|
* Gets the top-left corner coordinates of the given object.
|
|
@@ -967,23 +1136,60 @@
|
|
|
967
1136
|
const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
|
|
968
1137
|
this._setCanvasSizeInt(size.width, size.height);
|
|
969
1138
|
}
|
|
970
|
-
|
|
971
|
-
|
|
1139
|
+
/**
|
|
1140
|
+
* Whether post-load edits should resize the canvas to keep transformed content visible.
|
|
1141
|
+
*
|
|
1142
|
+
* @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
|
|
1143
|
+
* @private
|
|
1144
|
+
*/
|
|
1145
|
+
_shouldResizeCanvasToContentBounds() {
|
|
1146
|
+
return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Expands the canvas once so all provided objects remain visible after an edit.
|
|
1150
|
+
*
|
|
1151
|
+
* @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
|
|
1152
|
+
* @param {number} [padding=10] - Extra canvas space after the farthest object edge.
|
|
1153
|
+
* @returns {void}
|
|
1154
|
+
* @private
|
|
1155
|
+
*/
|
|
1156
|
+
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1157
|
+
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds())
|
|
972
1158
|
return;
|
|
973
1159
|
try {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1160
|
+
let requiredWidth = this.canvas.getWidth();
|
|
1161
|
+
let requiredHeight = this.canvas.getHeight();
|
|
1162
|
+
fabricObjects.forEach((fabricObject) => {
|
|
1163
|
+
if (!fabricObject)
|
|
1164
|
+
return;
|
|
1165
|
+
if (typeof fabricObject.setCoords === "function")
|
|
1166
|
+
fabricObject.setCoords();
|
|
1167
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1168
|
+
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1169
|
+
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1170
|
+
});
|
|
978
1171
|
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
979
1172
|
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
980
1173
|
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
981
1174
|
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
982
|
-
this.
|
|
1175
|
+
if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
|
|
1176
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1177
|
+
}
|
|
983
1178
|
} catch (error) {
|
|
984
|
-
this._reportWarning("
|
|
1179
|
+
this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
|
|
985
1180
|
}
|
|
986
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Expands the canvas so one object remains visible after an edit.
|
|
1184
|
+
*
|
|
1185
|
+
* @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
|
|
1186
|
+
* @param {number} [padding=10] - Extra canvas space after the object edge.
|
|
1187
|
+
* @returns {void}
|
|
1188
|
+
* @private
|
|
1189
|
+
*/
|
|
1190
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
1191
|
+
this._expandCanvasToFitObjects([fabricObject], padding);
|
|
1192
|
+
}
|
|
987
1193
|
/**
|
|
988
1194
|
* Scales the original image by a given factor, with animation.
|
|
989
1195
|
* Returns a promise that resolves when the scale animation is complete.
|
|
@@ -992,7 +1198,7 @@
|
|
|
992
1198
|
* @public
|
|
993
1199
|
*/
|
|
994
1200
|
scaleImage(factor, options = {}) {
|
|
995
|
-
return this.
|
|
1201
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
996
1202
|
}
|
|
997
1203
|
/**
|
|
998
1204
|
* Scales the original image by a given factor, with animation.
|
|
@@ -1031,7 +1237,7 @@
|
|
|
1031
1237
|
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1032
1238
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1033
1239
|
this.originalImage.setCoords();
|
|
1034
|
-
if (this.
|
|
1240
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1035
1241
|
this._updateCanvasSizeToImageBounds();
|
|
1036
1242
|
}
|
|
1037
1243
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
@@ -1057,7 +1263,7 @@
|
|
|
1057
1263
|
* @public
|
|
1058
1264
|
*/
|
|
1059
1265
|
rotateImage(degrees, options = {}) {
|
|
1060
|
-
return this.
|
|
1266
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
1061
1267
|
}
|
|
1062
1268
|
/**
|
|
1063
1269
|
* Rotates the original image by a given number of degrees, with animation.
|
|
@@ -1089,7 +1295,7 @@
|
|
|
1089
1295
|
return rotationAnimation.then(() => {
|
|
1090
1296
|
this.originalImage.set("angle", degrees);
|
|
1091
1297
|
this.originalImage.setCoords();
|
|
1092
|
-
if (this.
|
|
1298
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1093
1299
|
this._updateCanvasSizeToImageBounds();
|
|
1094
1300
|
}
|
|
1095
1301
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
@@ -1111,38 +1317,47 @@
|
|
|
1111
1317
|
}
|
|
1112
1318
|
/**
|
|
1113
1319
|
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
1114
|
-
*
|
|
1320
|
+
*
|
|
1321
|
+
* @returns {Promise<void>} Resolves when the reset history transition has been recorded.
|
|
1322
|
+
* @public
|
|
1115
1323
|
*/
|
|
1116
1324
|
resetImageTransform() {
|
|
1117
1325
|
if (!this.originalImage)
|
|
1118
1326
|
return Promise.resolve();
|
|
1119
|
-
return this.
|
|
1120
|
-
const before = this._serializeCanvasState();
|
|
1327
|
+
return this.animationQueue.add(async () => {
|
|
1328
|
+
const before = this._lastSnapshot || this._serializeCanvasState();
|
|
1121
1329
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1122
1330
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1123
1331
|
const after = this._serializeCanvasState();
|
|
1124
1332
|
this._pushStateTransition(before, after);
|
|
1125
|
-
}).catch((
|
|
1126
|
-
this._reportError("resetImageTransform() failed",
|
|
1333
|
+
}).catch((error) => {
|
|
1334
|
+
this._reportError("resetImageTransform() failed", error);
|
|
1127
1335
|
});
|
|
1128
1336
|
}
|
|
1129
1337
|
/**
|
|
1130
|
-
* @
|
|
1338
|
+
* Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
|
|
1339
|
+
*
|
|
1340
|
+
* @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
|
|
1341
|
+
* @returns {Promise<void>} Resolves when the image transform reset is complete.
|
|
1131
1342
|
*/
|
|
1132
1343
|
reset() {
|
|
1133
1344
|
return this.resetImageTransform();
|
|
1134
1345
|
}
|
|
1135
1346
|
/**
|
|
1136
|
-
* Restores a canvas state
|
|
1137
|
-
*
|
|
1347
|
+
* Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
|
|
1348
|
+
*
|
|
1349
|
+
* @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
|
|
1350
|
+
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
1351
|
+
* @public
|
|
1138
1352
|
*/
|
|
1139
|
-
loadFromState(
|
|
1140
|
-
if (!
|
|
1353
|
+
loadFromState(serializedState) {
|
|
1354
|
+
if (!serializedState || !this.canvas)
|
|
1141
1355
|
return Promise.resolve();
|
|
1142
1356
|
return new Promise((resolve) => {
|
|
1143
1357
|
try {
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1358
|
+
const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
|
|
1359
|
+
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1360
|
+
this.canvas.loadFromJSON(state, () => {
|
|
1146
1361
|
try {
|
|
1147
1362
|
this._hideAllMaskLabels();
|
|
1148
1363
|
const canvasObjects = this.canvas.getObjects();
|
|
@@ -1150,11 +1365,22 @@
|
|
|
1150
1365
|
if (this.originalImage) {
|
|
1151
1366
|
this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
|
|
1152
1367
|
this.canvas.sendToBack(this.originalImage);
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1368
|
+
const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
|
|
1369
|
+
const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
|
|
1370
|
+
const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
|
|
1371
|
+
if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
|
|
1372
|
+
this.baseImageScale = restoredBaseScale;
|
|
1373
|
+
}
|
|
1374
|
+
if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
|
|
1375
|
+
this.currentScale = restoredCurrentScale;
|
|
1376
|
+
} else {
|
|
1377
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1378
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1379
|
+
this.currentScale = imageScale / baseScale;
|
|
1380
|
+
}
|
|
1381
|
+
this.currentRotation = Number.isFinite(restoredCurrentRotation) ? restoredCurrentRotation : Number(this.originalImage.angle) || 0;
|
|
1157
1382
|
} else {
|
|
1383
|
+
this.baseImageScale = 1;
|
|
1158
1384
|
this.currentScale = 1;
|
|
1159
1385
|
this.currentRotation = 0;
|
|
1160
1386
|
}
|
|
@@ -1164,7 +1390,9 @@
|
|
|
1164
1390
|
this._rebindMaskEvents(mask);
|
|
1165
1391
|
mask.set(this._getMaskNormalStyle(mask));
|
|
1166
1392
|
});
|
|
1167
|
-
|
|
1393
|
+
const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
|
|
1394
|
+
const maxMaskId = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
|
|
1395
|
+
this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId ? Math.floor(restoredMaskCounter) : maxMaskId;
|
|
1168
1396
|
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1169
1397
|
if (!this._lastMask) {
|
|
1170
1398
|
this._lastMaskInitialLeft = null;
|
|
@@ -1191,13 +1419,18 @@
|
|
|
1191
1419
|
});
|
|
1192
1420
|
}
|
|
1193
1421
|
/**
|
|
1194
|
-
* Saves the current
|
|
1422
|
+
* Saves the current editable canvas state as an undoable history transition.
|
|
1423
|
+
*
|
|
1424
|
+
* Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
|
|
1425
|
+
* mask objects and restored by `loadFromState()`.
|
|
1426
|
+
*
|
|
1427
|
+
* @returns {void}
|
|
1428
|
+
* @public
|
|
1195
1429
|
*/
|
|
1196
1430
|
saveState() {
|
|
1197
1431
|
if (!this.canvas)
|
|
1198
1432
|
return;
|
|
1199
1433
|
const activeObject = this.canvas.getActiveObject();
|
|
1200
|
-
this._hideAllMaskLabels();
|
|
1201
1434
|
try {
|
|
1202
1435
|
const after = this._serializeCanvasState();
|
|
1203
1436
|
const before = this._lastSnapshot || after;
|
|
@@ -1219,12 +1452,23 @@
|
|
|
1219
1452
|
} catch (error) {
|
|
1220
1453
|
this._reportWarning("saveState: failed to save canvas snapshot", error);
|
|
1221
1454
|
} finally {
|
|
1222
|
-
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1455
|
+
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1223
1456
|
this._handleSelectionChanged([activeObject]);
|
|
1224
1457
|
}
|
|
1225
1458
|
this._updateUI();
|
|
1226
1459
|
}
|
|
1227
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Pushes a precomputed before/after state transition into history.
|
|
1463
|
+
*
|
|
1464
|
+
* Use this for operations such as crop and merge that build their snapshots around asynchronous image
|
|
1465
|
+
* loading, where the "after" state is already applied before the history command is recorded.
|
|
1466
|
+
*
|
|
1467
|
+
* @param {string} before - Serialized state before the operation.
|
|
1468
|
+
* @param {string} after - Serialized state after the operation.
|
|
1469
|
+
* @returns {void}
|
|
1470
|
+
* @private
|
|
1471
|
+
*/
|
|
1228
1472
|
_pushStateTransition(before, after) {
|
|
1229
1473
|
if (!before || !after)
|
|
1230
1474
|
return;
|
|
@@ -1242,6 +1486,9 @@
|
|
|
1242
1486
|
}
|
|
1243
1487
|
/**
|
|
1244
1488
|
* Undo the last state change, if possible.
|
|
1489
|
+
*
|
|
1490
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
|
|
1491
|
+
* @public
|
|
1245
1492
|
*/
|
|
1246
1493
|
undo() {
|
|
1247
1494
|
return this.historyManager.undo().then(() => {
|
|
@@ -1252,6 +1499,9 @@
|
|
|
1252
1499
|
}
|
|
1253
1500
|
/**
|
|
1254
1501
|
* Redo the next state change, if possible.
|
|
1502
|
+
*
|
|
1503
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
|
|
1504
|
+
* @public
|
|
1255
1505
|
*/
|
|
1256
1506
|
redo() {
|
|
1257
1507
|
return this.historyManager.redo().then(() => {
|
|
@@ -1267,7 +1517,7 @@
|
|
|
1267
1517
|
try {
|
|
1268
1518
|
mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
|
|
1269
1519
|
mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
|
|
1270
|
-
} catch (
|
|
1520
|
+
} catch (error) {
|
|
1271
1521
|
}
|
|
1272
1522
|
}
|
|
1273
1523
|
const metadata = {};
|
|
@@ -1305,23 +1555,32 @@
|
|
|
1305
1555
|
mask.on("mouseout", mouseout);
|
|
1306
1556
|
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
1307
1557
|
}
|
|
1308
|
-
/**
|
|
1558
|
+
/**
|
|
1309
1559
|
* Creates a mask and adds it to the canvas.
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
* @
|
|
1560
|
+
*
|
|
1561
|
+
* Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
|
|
1562
|
+
* after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
|
|
1563
|
+
* so controls and hit testing stay in sync with Fabric 5.x behavior.
|
|
1564
|
+
*
|
|
1565
|
+
* @param {Object} [config={}] - Optional mask configuration overrides.
|
|
1566
|
+
* @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
|
|
1567
|
+
* @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
|
|
1568
|
+
* @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
|
|
1569
|
+
* @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
|
|
1570
|
+
* @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
|
|
1571
|
+
* @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
|
|
1572
|
+
* @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
|
|
1573
|
+
* @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
|
|
1574
|
+
* @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
|
|
1575
|
+
* @param {number} [config.angle=0] - Rotation angle in degrees.
|
|
1576
|
+
* @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
|
|
1577
|
+
* @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
|
|
1578
|
+
* @param {boolean} [config.selectable=true] - Whether the mask can be selected.
|
|
1579
|
+
* @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
|
|
1580
|
+
* @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
|
|
1581
|
+
* @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
|
|
1582
|
+
* @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
|
|
1583
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1325
1584
|
* @public
|
|
1326
1585
|
*/
|
|
1327
1586
|
createMask(config = {}) {
|
|
@@ -1369,6 +1628,8 @@
|
|
|
1369
1628
|
}
|
|
1370
1629
|
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1371
1630
|
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1631
|
+
maskConfig.left = left;
|
|
1632
|
+
maskConfig.top = top;
|
|
1372
1633
|
let mask;
|
|
1373
1634
|
if (typeof maskConfig.fabricGenerator === "function") {
|
|
1374
1635
|
mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
|
|
@@ -1399,8 +1660,8 @@
|
|
|
1399
1660
|
break;
|
|
1400
1661
|
case "polygon": {
|
|
1401
1662
|
let polygonPoints = maskConfig.points || [];
|
|
1402
|
-
if (Array.isArray(polygonPoints) && polygonPoints.length
|
|
1403
|
-
polygonPoints = polygonPoints.map((point) => ({ x: Number(point.x), y: Number(point.y) })
|
|
1663
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length) {
|
|
1664
|
+
polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
|
|
1404
1665
|
}
|
|
1405
1666
|
mask = new fabric.Polygon(polygonPoints, {
|
|
1406
1667
|
left,
|
|
@@ -1423,7 +1684,6 @@
|
|
|
1423
1684
|
opacity: maskConfig.alpha,
|
|
1424
1685
|
angle: maskConfig.angle,
|
|
1425
1686
|
rx: maskConfig.rx,
|
|
1426
|
-
// Rounded Corners
|
|
1427
1687
|
ry: maskConfig.ry,
|
|
1428
1688
|
...maskConfig.styles
|
|
1429
1689
|
});
|
|
@@ -1441,6 +1701,7 @@
|
|
|
1441
1701
|
transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
|
|
1442
1702
|
stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
|
|
1443
1703
|
strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
|
|
1704
|
+
opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
|
|
1444
1705
|
strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
|
|
1445
1706
|
};
|
|
1446
1707
|
if (hasStyle("strokeDashArray"))
|
|
@@ -1448,7 +1709,7 @@
|
|
|
1448
1709
|
mask.set(maskSettings);
|
|
1449
1710
|
mask.setCoords();
|
|
1450
1711
|
mask.set({
|
|
1451
|
-
originalAlpha: maskConfig.alpha,
|
|
1712
|
+
originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
|
|
1452
1713
|
originalStroke: mask.stroke || "#ccc",
|
|
1453
1714
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
1454
1715
|
});
|
|
@@ -1477,7 +1738,11 @@
|
|
|
1477
1738
|
return mask;
|
|
1478
1739
|
}
|
|
1479
1740
|
/**
|
|
1480
|
-
* @
|
|
1741
|
+
* Backward-compatible alias for {@link ImageEditor#createMask}.
|
|
1742
|
+
*
|
|
1743
|
+
* @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
|
|
1744
|
+
* @param {Object} [config={}] - Mask configuration passed to createMask().
|
|
1745
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1481
1746
|
*/
|
|
1482
1747
|
addMask(config = {}) {
|
|
1483
1748
|
return this.createMask(config);
|
|
@@ -1551,6 +1816,23 @@
|
|
|
1551
1816
|
}
|
|
1552
1817
|
}
|
|
1553
1818
|
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Returns a stable zero-based creation index for label callbacks.
|
|
1821
|
+
*
|
|
1822
|
+
* Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
|
|
1823
|
+
* lifetime of a mask.
|
|
1824
|
+
*
|
|
1825
|
+
* @param {fabric.Object} mask - Mask object.
|
|
1826
|
+
* @returns {number} Stable zero-based creation index.
|
|
1827
|
+
* @private
|
|
1828
|
+
*/
|
|
1829
|
+
_getMaskCreationIndex(mask) {
|
|
1830
|
+
const maskId = Number(mask && mask.maskId);
|
|
1831
|
+
if (Number.isFinite(maskId) && maskId > 0)
|
|
1832
|
+
return Math.floor(maskId) - 1;
|
|
1833
|
+
const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1834
|
+
return Math.max(0, masks.indexOf(mask));
|
|
1835
|
+
}
|
|
1554
1836
|
/**
|
|
1555
1837
|
* Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
|
|
1556
1838
|
* The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
|
|
@@ -1582,9 +1864,7 @@
|
|
|
1582
1864
|
};
|
|
1583
1865
|
if (this.options.label) {
|
|
1584
1866
|
if (typeof this.options.label.getText === "function") {
|
|
1585
|
-
|
|
1586
|
-
const maskIndex = Math.max(0, masks.indexOf(mask));
|
|
1587
|
-
labelText = this.options.label.getText(mask, maskIndex);
|
|
1867
|
+
labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
|
|
1588
1868
|
}
|
|
1589
1869
|
if (this.options.label.textOptions) {
|
|
1590
1870
|
Object.assign(textOptions, this.options.label.textOptions);
|
|
@@ -1754,10 +2034,14 @@
|
|
|
1754
2034
|
});
|
|
1755
2035
|
}
|
|
1756
2036
|
/**
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
2037
|
+
* Flattens the current masks into the base image and reloads the flattened image.
|
|
2038
|
+
*
|
|
2039
|
+
* This removes editable mask objects after export and records the operation as one undoable history transition.
|
|
2040
|
+
* It does nothing when no base image or no masks exist.
|
|
2041
|
+
*
|
|
1759
2042
|
* @async
|
|
1760
|
-
* @returns {Promise<void>} Resolves when
|
|
2043
|
+
* @returns {Promise<void>} Resolves when the flattened image has been loaded.
|
|
2044
|
+
* @public
|
|
1761
2045
|
*/
|
|
1762
2046
|
async mergeMasks() {
|
|
1763
2047
|
if (!this.originalImage)
|
|
@@ -1771,49 +2055,58 @@
|
|
|
1771
2055
|
const beforeJson = this._serializeCanvasState();
|
|
1772
2056
|
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1773
2057
|
this.removeAllMasks({ saveHistory: false });
|
|
1774
|
-
await this.loadImage(merged);
|
|
2058
|
+
await this.loadImage(merged, { preserveScroll: true });
|
|
1775
2059
|
const afterJson = this._serializeCanvasState();
|
|
1776
2060
|
this._pushStateTransition(beforeJson, afterJson);
|
|
1777
|
-
} catch (
|
|
1778
|
-
this._reportError("merge error",
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
this._reportError("merge error", error);
|
|
1779
2063
|
}
|
|
1780
2064
|
}
|
|
1781
2065
|
/**
|
|
1782
|
-
* @
|
|
2066
|
+
* Backward-compatible alias for {@link ImageEditor#mergeMasks}.
|
|
2067
|
+
*
|
|
2068
|
+
* @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
|
|
2069
|
+
* @returns {Promise<void>} Resolves when mask flattening is complete.
|
|
1783
2070
|
*/
|
|
1784
2071
|
async merge() {
|
|
1785
2072
|
return this.mergeMasks();
|
|
1786
2073
|
}
|
|
1787
2074
|
/**
|
|
1788
|
-
* Triggers a JPEG image download of the current canvas
|
|
2075
|
+
* Triggers a JPEG image download of the current canvas.
|
|
2076
|
+
*
|
|
1789
2077
|
* The image area and multiplier are controlled by options.
|
|
1790
2078
|
* @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
|
|
2079
|
+
* @returns {void}
|
|
2080
|
+
* @public
|
|
1791
2081
|
*/
|
|
1792
2082
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1793
2083
|
if (!this.originalImage)
|
|
1794
2084
|
return;
|
|
1795
2085
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1796
|
-
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((
|
|
2086
|
+
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
|
|
1797
2087
|
const link = document.createElement("a");
|
|
1798
2088
|
link.download = fileName;
|
|
1799
|
-
link.href =
|
|
2089
|
+
link.href = imageBase64;
|
|
1800
2090
|
document.body.appendChild(link);
|
|
1801
2091
|
link.click();
|
|
1802
2092
|
document.body.removeChild(link);
|
|
1803
|
-
}).catch((
|
|
2093
|
+
}).catch((error) => this._reportError("download error", error));
|
|
1804
2094
|
}
|
|
1805
2095
|
/**
|
|
1806
|
-
* Exports the image as a Base64-encoded
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
2096
|
+
* Exports the current image as a Base64-encoded data URL.
|
|
2097
|
+
*
|
|
2098
|
+
* When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
|
|
2099
|
+
* temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
|
|
2100
|
+
*
|
|
1809
2101
|
* @async
|
|
1810
2102
|
* @param {Object} [options={}] - Export options.
|
|
1811
2103
|
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1812
2104
|
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1813
2105
|
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1814
2106
|
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1815
|
-
* @returns {Promise<string>}
|
|
2107
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1816
2108
|
* @throws {Error} If there is no image loaded.
|
|
2109
|
+
* @public
|
|
1817
2110
|
*/
|
|
1818
2111
|
async exportImageBase64(options = {}) {
|
|
1819
2112
|
if (!this.originalImage)
|
|
@@ -1833,12 +2126,9 @@
|
|
|
1833
2126
|
this.canvas.renderAll();
|
|
1834
2127
|
this.originalImage.setCoords();
|
|
1835
2128
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1836
|
-
const
|
|
2129
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1837
2130
|
return await this._exportCanvasRegionToDataURL({
|
|
1838
|
-
|
|
1839
|
-
sy,
|
|
1840
|
-
sw,
|
|
1841
|
-
sh,
|
|
2131
|
+
...exportRegion,
|
|
1842
2132
|
multiplier,
|
|
1843
2133
|
quality,
|
|
1844
2134
|
format
|
|
@@ -1875,12 +2165,9 @@
|
|
|
1875
2165
|
this.canvas.renderAll();
|
|
1876
2166
|
this.originalImage.setCoords();
|
|
1877
2167
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1878
|
-
const
|
|
2168
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1879
2169
|
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
1880
|
-
|
|
1881
|
-
sy,
|
|
1882
|
-
sw,
|
|
1883
|
-
sh,
|
|
2170
|
+
...exportRegion,
|
|
1884
2171
|
multiplier,
|
|
1885
2172
|
quality,
|
|
1886
2173
|
format
|
|
@@ -1905,14 +2192,20 @@
|
|
|
1905
2192
|
return finalBase64;
|
|
1906
2193
|
}
|
|
1907
2194
|
/**
|
|
1908
|
-
* @
|
|
2195
|
+
* Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
|
|
2196
|
+
*
|
|
2197
|
+
* @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
|
|
2198
|
+
* @param {Object} [options={}] - Export options passed to exportImageBase64().
|
|
2199
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1909
2200
|
*/
|
|
1910
2201
|
async getImageBase64(options = {}) {
|
|
1911
2202
|
return this.exportImageBase64(options);
|
|
1912
2203
|
}
|
|
1913
2204
|
/**
|
|
1914
|
-
* Exports the current
|
|
1915
|
-
*
|
|
2205
|
+
* Exports the current image as a File object.
|
|
2206
|
+
*
|
|
2207
|
+
* The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
|
|
2208
|
+
* Supported output formats are JPEG, PNG, and WebP.
|
|
1916
2209
|
*
|
|
1917
2210
|
* @async
|
|
1918
2211
|
* @param {Object} [options={}] - Export options.
|
|
@@ -1937,23 +2230,23 @@
|
|
|
1937
2230
|
fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
|
|
1938
2231
|
} = options;
|
|
1939
2232
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
1940
|
-
let
|
|
2233
|
+
let imageBase64;
|
|
1941
2234
|
if (mergeMask) {
|
|
1942
|
-
|
|
2235
|
+
imageBase64 = await this.exportImageBase64({
|
|
1943
2236
|
exportImageArea: true,
|
|
1944
2237
|
multiplier,
|
|
1945
2238
|
quality,
|
|
1946
2239
|
fileType: safeFileType
|
|
1947
2240
|
});
|
|
1948
2241
|
} else {
|
|
1949
|
-
|
|
2242
|
+
imageBase64 = await this.exportImageBase64({
|
|
1950
2243
|
exportImageArea: false,
|
|
1951
2244
|
multiplier,
|
|
1952
2245
|
quality,
|
|
1953
2246
|
fileType: safeFileType
|
|
1954
2247
|
});
|
|
1955
2248
|
}
|
|
1956
|
-
let imageDataUrl =
|
|
2249
|
+
let imageDataUrl = imageBase64;
|
|
1957
2250
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
1958
2251
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
1959
2252
|
const imageElement = new window.Image();
|
|
@@ -1972,7 +2265,7 @@
|
|
|
1972
2265
|
}
|
|
1973
2266
|
};
|
|
1974
2267
|
imageElement.onerror = reject;
|
|
1975
|
-
imageElement.src =
|
|
2268
|
+
imageElement.src = imageBase64;
|
|
1976
2269
|
});
|
|
1977
2270
|
}
|
|
1978
2271
|
const binaryString = atob(imageDataUrl.split(",")[1]);
|
|
@@ -2047,7 +2340,12 @@
|
|
|
2047
2340
|
this._cropHandlers = [];
|
|
2048
2341
|
}
|
|
2049
2342
|
/**
|
|
2050
|
-
*
|
|
2343
|
+
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
2344
|
+
*
|
|
2345
|
+
* Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
|
|
2346
|
+
* cropping when `crop.hideMasksDuringCrop` is enabled.
|
|
2347
|
+
*
|
|
2348
|
+
* @returns {void}
|
|
2051
2349
|
* @public
|
|
2052
2350
|
*/
|
|
2053
2351
|
enterCropMode() {
|
|
@@ -2064,8 +2362,14 @@
|
|
|
2064
2362
|
const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
|
|
2065
2363
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2066
2364
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2067
|
-
const
|
|
2068
|
-
const
|
|
2365
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
|
|
2366
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
|
|
2367
|
+
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
2368
|
+
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
2369
|
+
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
2370
|
+
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
2371
|
+
const width = minCropWidth;
|
|
2372
|
+
const height = minCropHeight;
|
|
2069
2373
|
const cropRect = new fabric.Rect({
|
|
2070
2374
|
left,
|
|
2071
2375
|
top,
|
|
@@ -2082,7 +2386,8 @@
|
|
|
2082
2386
|
cornerSize: 8,
|
|
2083
2387
|
objectCaching: false,
|
|
2084
2388
|
originX: "left",
|
|
2085
|
-
originY: "top"
|
|
2389
|
+
originY: "top",
|
|
2390
|
+
lockScalingFlip: true
|
|
2086
2391
|
});
|
|
2087
2392
|
this.canvas.add(cropRect);
|
|
2088
2393
|
cropRect.isCropRect = true;
|
|
@@ -2108,6 +2413,11 @@
|
|
|
2108
2413
|
});
|
|
2109
2414
|
const handleCropRectModified = () => {
|
|
2110
2415
|
try {
|
|
2416
|
+
const cropWidth = Math.max(1, Number(cropRect.width) || 1);
|
|
2417
|
+
const cropHeight = Math.max(1, Number(cropRect.height) || 1);
|
|
2418
|
+
const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
|
|
2419
|
+
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
2420
|
+
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
2111
2421
|
cropRect.setCoords();
|
|
2112
2422
|
this.canvas.requestRenderAll();
|
|
2113
2423
|
} catch (error) {
|
|
@@ -2128,7 +2438,9 @@
|
|
|
2128
2438
|
this.canvas.renderAll();
|
|
2129
2439
|
}
|
|
2130
2440
|
/**
|
|
2131
|
-
*
|
|
2441
|
+
* Cancels crop mode and removes the temporary crop rectangle.
|
|
2442
|
+
*
|
|
2443
|
+
* @returns {void}
|
|
2132
2444
|
* @public
|
|
2133
2445
|
*/
|
|
2134
2446
|
cancelCrop() {
|
|
@@ -2144,8 +2456,14 @@
|
|
|
2144
2456
|
this.canvas.renderAll();
|
|
2145
2457
|
}
|
|
2146
2458
|
/**
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2459
|
+
* Applies the current crop rectangle to the base image.
|
|
2460
|
+
*
|
|
2461
|
+
* Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
|
|
2462
|
+
* region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
|
|
2463
|
+
* single undoable history transition.
|
|
2464
|
+
*
|
|
2465
|
+
* @async
|
|
2466
|
+
* @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
|
|
2149
2467
|
* @public
|
|
2150
2468
|
*/
|
|
2151
2469
|
async applyCrop() {
|
|
@@ -2153,7 +2471,7 @@
|
|
|
2153
2471
|
return;
|
|
2154
2472
|
this._cropRect.setCoords();
|
|
2155
2473
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
2156
|
-
const
|
|
2474
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds);
|
|
2157
2475
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2158
2476
|
this._restoreCropObjectState();
|
|
2159
2477
|
let beforeJson = null;
|
|
@@ -2171,13 +2489,13 @@
|
|
|
2171
2489
|
try {
|
|
2172
2490
|
mask.setCoords();
|
|
2173
2491
|
const maskBounds = mask.getBoundingRect(true, true);
|
|
2174
|
-
const intersectsCrop = maskBounds.left <
|
|
2492
|
+
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;
|
|
2175
2493
|
this._removeLabelForMask(mask);
|
|
2176
2494
|
this.canvas.remove(mask);
|
|
2177
2495
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2178
2496
|
mask.set({
|
|
2179
|
-
left: (mask.left || 0) -
|
|
2180
|
-
top: (mask.top || 0) -
|
|
2497
|
+
left: (mask.left || 0) - cropRegion.sourceX,
|
|
2498
|
+
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2181
2499
|
visible: true
|
|
2182
2500
|
});
|
|
2183
2501
|
mask.setCoords();
|
|
@@ -2201,10 +2519,7 @@
|
|
|
2201
2519
|
let croppedBase64;
|
|
2202
2520
|
try {
|
|
2203
2521
|
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2204
|
-
|
|
2205
|
-
sy,
|
|
2206
|
-
sw,
|
|
2207
|
-
sh,
|
|
2522
|
+
...cropRegion,
|
|
2208
2523
|
multiplier: 1,
|
|
2209
2524
|
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2210
2525
|
format: "jpeg"
|
|
@@ -2226,21 +2541,21 @@
|
|
|
2226
2541
|
this._updateMaskList();
|
|
2227
2542
|
this.canvas.renderAll();
|
|
2228
2543
|
}
|
|
2229
|
-
} catch (
|
|
2230
|
-
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed",
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
|
|
2231
2546
|
return;
|
|
2232
2547
|
}
|
|
2233
2548
|
let afterJson = null;
|
|
2234
2549
|
try {
|
|
2235
2550
|
afterJson = this._serializeCanvasState();
|
|
2236
|
-
} catch (
|
|
2237
|
-
this._reportWarning("applyCrop: failed to serialize after state",
|
|
2551
|
+
} catch (error) {
|
|
2552
|
+
this._reportWarning("applyCrop: failed to serialize after state", error);
|
|
2238
2553
|
afterJson = null;
|
|
2239
2554
|
}
|
|
2240
2555
|
try {
|
|
2241
2556
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2242
|
-
} catch (
|
|
2243
|
-
this._reportWarning("applyCrop: failed to push history command",
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
this._reportWarning("applyCrop: failed to push history command", error);
|
|
2244
2559
|
}
|
|
2245
2560
|
this._updateUI();
|
|
2246
2561
|
this.canvas.renderAll();
|
|
@@ -2333,7 +2648,7 @@
|
|
|
2333
2648
|
return element.getAttribute("aria-disabled") === "true";
|
|
2334
2649
|
}
|
|
2335
2650
|
/**
|
|
2336
|
-
*
|
|
2651
|
+
* Updates placeholder and canvas container visibility based on whether an image is loaded.
|
|
2337
2652
|
* @private
|
|
2338
2653
|
*/
|
|
2339
2654
|
_updatePlaceholderStatus() {
|
|
@@ -2342,12 +2657,13 @@
|
|
|
2342
2657
|
this._setPlaceholderVisible(!this.originalImage);
|
|
2343
2658
|
}
|
|
2344
2659
|
/**
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
2660
|
+
* Shows or hides the placeholder and canvas container.
|
|
2661
|
+
*
|
|
2662
|
+
* @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
|
|
2347
2663
|
* @private
|
|
2348
2664
|
*/
|
|
2349
2665
|
_setPlaceholderVisible(show) {
|
|
2350
|
-
if (!this.placeholderElement)
|
|
2666
|
+
if (!this.placeholderElement || !this.containerElement)
|
|
2351
2667
|
return;
|
|
2352
2668
|
if (show) {
|
|
2353
2669
|
this.placeholderElement.classList.remove("d-none");
|
|
@@ -2383,20 +2699,20 @@
|
|
|
2383
2699
|
if (this._cropRect) {
|
|
2384
2700
|
try {
|
|
2385
2701
|
this.canvas.remove(this._cropRect);
|
|
2386
|
-
} catch (
|
|
2702
|
+
} catch (error) {
|
|
2387
2703
|
}
|
|
2388
2704
|
this._cropRect = null;
|
|
2389
2705
|
}
|
|
2390
2706
|
if (this.containerElement && this._containerOriginalOverflow !== void 0) {
|
|
2391
2707
|
try {
|
|
2392
2708
|
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
2393
|
-
} catch (
|
|
2709
|
+
} catch (error) {
|
|
2394
2710
|
}
|
|
2395
2711
|
}
|
|
2396
2712
|
if (this.canvas) {
|
|
2397
2713
|
try {
|
|
2398
2714
|
this.canvas.dispose();
|
|
2399
|
-
} catch (
|
|
2715
|
+
} catch (error) {
|
|
2400
2716
|
}
|
|
2401
2717
|
this.canvas = null;
|
|
2402
2718
|
this.canvasElement = null;
|
|
@@ -2407,54 +2723,52 @@
|
|
|
2407
2723
|
};
|
|
2408
2724
|
var AnimationQueue = class {
|
|
2409
2725
|
/**
|
|
2410
|
-
* Creates
|
|
2411
|
-
*
|
|
2412
|
-
* @constructor
|
|
2726
|
+
* Creates an empty animation queue.
|
|
2413
2727
|
*/
|
|
2414
2728
|
constructor() {
|
|
2415
|
-
this.
|
|
2416
|
-
this.
|
|
2729
|
+
this.animationTasks = [];
|
|
2730
|
+
this.isRunning = false;
|
|
2417
2731
|
}
|
|
2418
2732
|
/**
|
|
2419
2733
|
* Adds an animation function to the queue.
|
|
2420
2734
|
*
|
|
2421
|
-
* @param
|
|
2422
|
-
* @returns {Promise
|
|
2735
|
+
* @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
|
|
2736
|
+
* @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
|
|
2423
2737
|
*/
|
|
2424
2738
|
async add(animationFn) {
|
|
2425
2739
|
return new Promise((resolve, reject) => {
|
|
2426
|
-
this.
|
|
2427
|
-
if (!this.
|
|
2428
|
-
this.
|
|
2740
|
+
this.animationTasks.push({ animationFn, resolve, reject });
|
|
2741
|
+
if (!this.isRunning) {
|
|
2742
|
+
this._drainQueue();
|
|
2429
2743
|
}
|
|
2430
2744
|
});
|
|
2431
2745
|
}
|
|
2432
2746
|
/**
|
|
2433
|
-
*
|
|
2747
|
+
* Runs queued animation tasks sequentially until the queue is empty.
|
|
2434
2748
|
*
|
|
2435
2749
|
* @private
|
|
2436
2750
|
* @returns {Promise<void>}
|
|
2437
2751
|
*/
|
|
2438
|
-
async
|
|
2439
|
-
if (this.
|
|
2440
|
-
this.
|
|
2752
|
+
async _drainQueue() {
|
|
2753
|
+
if (this.animationTasks.length === 0) {
|
|
2754
|
+
this.isRunning = false;
|
|
2441
2755
|
return;
|
|
2442
2756
|
}
|
|
2443
|
-
this.
|
|
2444
|
-
const {
|
|
2757
|
+
this.isRunning = true;
|
|
2758
|
+
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
2445
2759
|
try {
|
|
2446
|
-
const result = await
|
|
2760
|
+
const result = await animationFn();
|
|
2447
2761
|
resolve(result);
|
|
2448
2762
|
} catch (error) {
|
|
2449
2763
|
reject(error);
|
|
2450
2764
|
}
|
|
2451
|
-
this.
|
|
2765
|
+
await this._drainQueue();
|
|
2452
2766
|
}
|
|
2453
2767
|
};
|
|
2454
2768
|
var Command = class {
|
|
2455
2769
|
/**
|
|
2456
|
-
* @param {
|
|
2457
|
-
* @param {
|
|
2770
|
+
* @param {HistoryTaskCallback} execute - Function that performs the action.
|
|
2771
|
+
* @param {HistoryTaskCallback} undo - Function that reverts the action.
|
|
2458
2772
|
*/
|
|
2459
2773
|
constructor(execute, undo) {
|
|
2460
2774
|
this.execute = execute;
|
|
@@ -2463,7 +2777,7 @@
|
|
|
2463
2777
|
};
|
|
2464
2778
|
var HistoryManager = class {
|
|
2465
2779
|
/**
|
|
2466
|
-
* @param {number} [maxSize=50]
|
|
2780
|
+
* @param {number} [maxSize=50] - Maximum number of commands to keep in history.
|
|
2467
2781
|
*/
|
|
2468
2782
|
constructor(maxSize = 50) {
|
|
2469
2783
|
this.history = [];
|
|
@@ -2471,11 +2785,24 @@
|
|
|
2471
2785
|
this.maxSize = maxSize;
|
|
2472
2786
|
this.pending = Promise.resolve();
|
|
2473
2787
|
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Queues a history task after the previously queued undo/redo task completes.
|
|
2790
|
+
*
|
|
2791
|
+
* @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
|
|
2792
|
+
* @returns {Promise<void>} Resolves or rejects with the queued task result.
|
|
2793
|
+
* @private
|
|
2794
|
+
*/
|
|
2474
2795
|
enqueue(task) {
|
|
2475
|
-
const
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2796
|
+
const nextTask = this.pending.then(task, task);
|
|
2797
|
+
let pendingAfterTask;
|
|
2798
|
+
const resetPending = () => {
|
|
2799
|
+
if (this.pending === pendingAfterTask) {
|
|
2800
|
+
this.pending = Promise.resolve();
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
2804
|
+
this.pending = pendingAfterTask;
|
|
2805
|
+
return nextTask;
|
|
2479
2806
|
}
|
|
2480
2807
|
/**
|
|
2481
2808
|
* Executes a new command and pushes it onto the history stack.
|
|
@@ -2525,7 +2852,7 @@
|
|
|
2525
2852
|
/**
|
|
2526
2853
|
* Undoes the last executed command if possible.
|
|
2527
2854
|
*
|
|
2528
|
-
* @returns {void}
|
|
2855
|
+
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
2529
2856
|
*/
|
|
2530
2857
|
undo() {
|
|
2531
2858
|
return this.enqueue(async () => {
|
|
@@ -2539,7 +2866,7 @@
|
|
|
2539
2866
|
/**
|
|
2540
2867
|
* Redoes the next command in history if possible.
|
|
2541
2868
|
*
|
|
2542
|
-
* @returns {void}
|
|
2869
|
+
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
2543
2870
|
*/
|
|
2544
2871
|
redo() {
|
|
2545
2872
|
return this.enqueue(async () => {
|