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