@bensitu/image-editor 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image-editor.esm.js +1019 -375
- 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 +1019 -375
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +1019 -375
- 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 +25 -12
- package/package.json +3 -5
- package/src/image-editor.js +1105 -396
package/src/image-editor.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file image-editor.js
|
|
3
3
|
* @module image-editor
|
|
4
|
-
* @version 1.
|
|
4
|
+
* @version 1.4.1
|
|
5
5
|
* @author Ben Situ
|
|
6
6
|
* @license MIT
|
|
7
7
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
let fabric = null;
|
|
11
|
+
const INTERNAL_OPERATION_TOKEN = Symbol('ImageEditorInternalOperation');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
|
|
@@ -196,6 +197,8 @@ function ensureFabric() {
|
|
|
196
197
|
downsampleMaxWidth: 4000,
|
|
197
198
|
downsampleMaxHeight: 3000,
|
|
198
199
|
downsampleQuality: 0.92,
|
|
200
|
+
preserveSourceFormat: true,
|
|
201
|
+
downsampleMimeType: null,
|
|
199
202
|
imageLoadTimeoutMs: 30000,
|
|
200
203
|
|
|
201
204
|
exportMultiplier: 1,
|
|
@@ -250,11 +253,16 @@ function ensureFabric() {
|
|
|
250
253
|
this.currentRotation = 0;
|
|
251
254
|
this.maskCounter = 0;
|
|
252
255
|
this.isAnimating = false;
|
|
256
|
+
this._isLoading = false;
|
|
257
|
+
this._activeOperationName = null;
|
|
258
|
+
this._activeOperationToken = null;
|
|
253
259
|
this.elements = {};
|
|
254
260
|
this.isImageLoadedToCanvas = false;
|
|
255
261
|
this.maxHistorySize = 50;
|
|
256
262
|
|
|
257
263
|
this._handlersByElementKey = {};
|
|
264
|
+
this._elementCache = {};
|
|
265
|
+
this._elementOriginalPointerEvents = new Map();
|
|
258
266
|
|
|
259
267
|
this._lastMask = null;
|
|
260
268
|
this._lastMaskInitialLeft = null;
|
|
@@ -267,8 +275,14 @@ function ensureFabric() {
|
|
|
267
275
|
this._cropHandlers = [];
|
|
268
276
|
this._cropPrevEvented = null;
|
|
269
277
|
this._prevSelectionSetting = undefined;
|
|
270
|
-
this._containerOriginalOverflow =
|
|
278
|
+
this._containerOriginalOverflow = null;
|
|
279
|
+
this._lastContainerViewportSize = null;
|
|
280
|
+
this._canvasElementOriginalStyle = null;
|
|
281
|
+
this._visibilityStateByElement = new WeakMap();
|
|
271
282
|
this._scrollbarSizeCache = null;
|
|
283
|
+
this._activeAnimationRejectors = new Set();
|
|
284
|
+
this._disposed = false;
|
|
285
|
+
this._initialized = false;
|
|
272
286
|
|
|
273
287
|
this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
|
|
274
288
|
|
|
@@ -341,6 +355,20 @@ function ensureFabric() {
|
|
|
341
355
|
*/
|
|
342
356
|
init(idMap = {}) {
|
|
343
357
|
if (!this._fabricLoaded) return;
|
|
358
|
+
if (this._initialized || this.canvas) this.dispose();
|
|
359
|
+
this._disposed = false;
|
|
360
|
+
this._initialized = true;
|
|
361
|
+
this.animationQueue = new AnimationQueue();
|
|
362
|
+
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
363
|
+
this._visibilityStateByElement = new WeakMap();
|
|
364
|
+
this._activeAnimationRejectors = new Set();
|
|
365
|
+
this._isLoading = false;
|
|
366
|
+
this._activeOperationName = null;
|
|
367
|
+
this._activeOperationToken = null;
|
|
368
|
+
this._elementOriginalPointerEvents = new Map();
|
|
369
|
+
this._containerOriginalOverflow = null;
|
|
370
|
+
this._lastContainerViewportSize = null;
|
|
371
|
+
this._canvasElementOriginalStyle = null;
|
|
344
372
|
|
|
345
373
|
const defaults = {
|
|
346
374
|
canvas: 'fabricCanvas',
|
|
@@ -369,6 +397,7 @@ function ensureFabric() {
|
|
|
369
397
|
};
|
|
370
398
|
|
|
371
399
|
this.elements = { ...defaults, ...idMap };
|
|
400
|
+
this._elementCache = {};
|
|
372
401
|
|
|
373
402
|
this._initCanvas();
|
|
374
403
|
this._bindEvents();
|
|
@@ -413,19 +442,25 @@ function ensureFabric() {
|
|
|
413
442
|
* @private
|
|
414
443
|
*/
|
|
415
444
|
_initCanvas() {
|
|
416
|
-
const canvasElement =
|
|
445
|
+
const canvasElement = this._getElement('canvas');
|
|
417
446
|
if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
|
|
418
447
|
this.canvasElement = canvasElement;
|
|
448
|
+
this._canvasElementOriginalStyle = {
|
|
449
|
+
display: canvasElement.style.display || '',
|
|
450
|
+
width: canvasElement.style.width || '',
|
|
451
|
+
height: canvasElement.style.height || '',
|
|
452
|
+
maxWidth: canvasElement.style.maxWidth || ''
|
|
453
|
+
};
|
|
419
454
|
|
|
420
455
|
// Decide which element acts as the viewport for size fallback and scrolling.
|
|
421
456
|
if (this.elements.canvasContainer) {
|
|
422
|
-
const containerElement =
|
|
457
|
+
const containerElement = this._getElement('canvasContainer');
|
|
423
458
|
this.containerElement = containerElement || canvasElement.parentElement;
|
|
424
459
|
} else {
|
|
425
460
|
this.containerElement = canvasElement.parentElement;
|
|
426
461
|
}
|
|
427
462
|
|
|
428
|
-
this.placeholderElement =
|
|
463
|
+
this.placeholderElement = this._getElement('imgPlaceholder') || null;
|
|
429
464
|
|
|
430
465
|
// Prefer a measured container size when it is available.
|
|
431
466
|
let initialWidth = this.options.canvasWidth;
|
|
@@ -436,6 +471,11 @@ function ensureFabric() {
|
|
|
436
471
|
if (containerWidth > 0 && containerHeight > 0) {
|
|
437
472
|
initialWidth = containerWidth;
|
|
438
473
|
initialHeight = containerHeight;
|
|
474
|
+
|
|
475
|
+
this._lastContainerViewportSize = {
|
|
476
|
+
width: containerWidth,
|
|
477
|
+
height: containerHeight
|
|
478
|
+
};
|
|
439
479
|
}
|
|
440
480
|
}
|
|
441
481
|
|
|
@@ -460,6 +500,24 @@ function ensureFabric() {
|
|
|
460
500
|
this.canvasElement.style.display = 'block';
|
|
461
501
|
}
|
|
462
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Returns a configured DOM element and caches lookups for hot UI paths.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} key - Key in the configured element map.
|
|
507
|
+
* @returns {HTMLElement|null} The configured element, or null when missing.
|
|
508
|
+
* @private
|
|
509
|
+
*/
|
|
510
|
+
_getElement(key) {
|
|
511
|
+
const id = this.elements && this.elements[key];
|
|
512
|
+
if (!id) return null;
|
|
513
|
+
if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
|
|
514
|
+
return this._elementCache[key];
|
|
515
|
+
}
|
|
516
|
+
const element = document.getElementById(id);
|
|
517
|
+
if (this._elementCache) this._elementCache[key] = element || null;
|
|
518
|
+
return element || null;
|
|
519
|
+
}
|
|
520
|
+
|
|
463
521
|
/**
|
|
464
522
|
* Records a history entry after Fabric finishes modifying one or more masks.
|
|
465
523
|
*
|
|
@@ -504,9 +562,7 @@ function ensureFabric() {
|
|
|
504
562
|
*/
|
|
505
563
|
_syncContainerOverflow(options = {}) {
|
|
506
564
|
if (!this.containerElement || !this.containerElement.style) return;
|
|
507
|
-
|
|
508
|
-
this._containerOriginalOverflow = this.containerElement.style.overflow || '';
|
|
509
|
-
}
|
|
565
|
+
this._captureContainerOverflowState();
|
|
510
566
|
|
|
511
567
|
const shouldPreserveScroll = options.preserveScroll === true;
|
|
512
568
|
if (this.options.coverImageToCanvas) {
|
|
@@ -522,10 +578,33 @@ function ensureFabric() {
|
|
|
522
578
|
this.containerElement.scrollTop = 0;
|
|
523
579
|
}
|
|
524
580
|
} else {
|
|
525
|
-
this.
|
|
581
|
+
this._restoreContainerOverflowState();
|
|
526
582
|
}
|
|
527
583
|
}
|
|
528
584
|
|
|
585
|
+
_captureContainerOverflowState() {
|
|
586
|
+
if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
|
|
587
|
+
this._containerOriginalOverflow = {
|
|
588
|
+
overflow: this.containerElement.style.overflow || '',
|
|
589
|
+
overflowX: this.containerElement.style.overflowX || '',
|
|
590
|
+
overflowY: this.containerElement.style.overflowY || ''
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
_restoreContainerOverflowState() {
|
|
595
|
+
if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
|
|
596
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
|
|
597
|
+
this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
|
|
598
|
+
this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
_restoreContainerOverflowSnapshot(snapshot) {
|
|
602
|
+
if (!this.containerElement || !this.containerElement.style || !snapshot) return;
|
|
603
|
+
this.containerElement.style.overflow = snapshot.overflow || '';
|
|
604
|
+
this.containerElement.style.overflowX = snapshot.overflowX || '';
|
|
605
|
+
this.containerElement.style.overflowY = snapshot.overflowY || '';
|
|
606
|
+
}
|
|
607
|
+
|
|
529
608
|
/**
|
|
530
609
|
* DOM / UI bindings
|
|
531
610
|
* @private
|
|
@@ -533,54 +612,61 @@ function ensureFabric() {
|
|
|
533
612
|
_bindEvents() {
|
|
534
613
|
// Click anywhere on the upload area opens the native file dialog
|
|
535
614
|
this._bindIfExists('uploadArea', 'click', () => {
|
|
536
|
-
const uploadAreaElement =
|
|
615
|
+
const uploadAreaElement = this._getElement('uploadArea');
|
|
537
616
|
if (this._isElementDisabled(uploadAreaElement)) return;
|
|
538
|
-
|
|
617
|
+
this._getElement('imageInput')?.click();
|
|
539
618
|
});
|
|
540
619
|
// File-input change
|
|
541
620
|
this._bindIfExists('imageInput', 'change', (event) => {
|
|
542
621
|
const file = event.target.files && event.target.files[0];
|
|
543
|
-
if (file)
|
|
622
|
+
if (file) {
|
|
623
|
+
this._loadImageFile(file)
|
|
624
|
+
.catch(error => this._reportError('Image file could not be loaded', error))
|
|
625
|
+
.finally(() => {
|
|
626
|
+
event.target.value = '';
|
|
627
|
+
});
|
|
628
|
+
}
|
|
544
629
|
});
|
|
545
630
|
// Zoom & reset
|
|
546
|
-
this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
|
|
547
|
-
this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
|
|
548
|
-
this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
|
|
631
|
+
this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
632
|
+
this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
633
|
+
this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
|
|
549
634
|
// Mask management
|
|
550
635
|
this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
|
|
551
636
|
this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
|
|
552
637
|
this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
|
|
553
638
|
// Merge + download
|
|
554
|
-
this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
|
|
639
|
+
this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
|
|
555
640
|
this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
|
|
556
641
|
// Undo + Redo
|
|
557
|
-
this._bindIfExists('undoBtn', 'click', () => this.undo());
|
|
558
|
-
this._bindIfExists('redoBtn', 'click', () => this.redo());
|
|
642
|
+
this._bindIfExists('undoBtn', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
|
|
643
|
+
this._bindIfExists('redoBtn', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
|
|
559
644
|
|
|
560
645
|
// Rotation buttons (step can be overridden by two input fields)
|
|
561
646
|
this._bindIfExists('rotateLeftBtn', 'click', () => {
|
|
562
|
-
const rotationInputElement =
|
|
647
|
+
const rotationInputElement = this._getElement('rotationLeftInput');
|
|
563
648
|
let step = this.options.rotationStep;
|
|
564
649
|
if (rotationInputElement) {
|
|
565
650
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
566
651
|
if (!isNaN(parsedStep)) step = parsedStep;
|
|
567
652
|
}
|
|
568
|
-
this.rotateImage(this.currentRotation - step);
|
|
653
|
+
this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
|
|
569
654
|
});
|
|
570
655
|
this._bindIfExists('rotateRightBtn', 'click', () => {
|
|
571
|
-
const rotationInputElement =
|
|
656
|
+
const rotationInputElement = this._getElement('rotationRightInput');
|
|
572
657
|
let step = this.options.rotationStep;
|
|
573
658
|
if (rotationInputElement) {
|
|
574
659
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
575
660
|
if (!isNaN(parsedStep)) step = parsedStep;
|
|
576
661
|
}
|
|
577
|
-
this.rotateImage(this.currentRotation + step);
|
|
662
|
+
this.rotateImage(this.currentRotation + step).catch(error => this._reportError('rotateImage failed', error));
|
|
578
663
|
});
|
|
579
664
|
|
|
580
665
|
// Crop bindings (optional: bound only if element IDs exist in elements)
|
|
581
666
|
this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
|
|
582
667
|
this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
|
|
583
668
|
this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
|
|
669
|
+
this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
|
|
584
670
|
}
|
|
585
671
|
|
|
586
672
|
/**
|
|
@@ -592,7 +678,7 @@ function ensureFabric() {
|
|
|
592
678
|
* @private
|
|
593
679
|
*/
|
|
594
680
|
_bindIfExists(key, eventName, handler) {
|
|
595
|
-
const element =
|
|
681
|
+
const element = this._getElement(key);
|
|
596
682
|
if (element) {
|
|
597
683
|
element.addEventListener(eventName, handler);
|
|
598
684
|
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
@@ -605,14 +691,37 @@ function ensureFabric() {
|
|
|
605
691
|
* Reads an image File as a data URL and loads it into the Fabric canvas.
|
|
606
692
|
*
|
|
607
693
|
* @param {File} file - Image file selected by the user.
|
|
694
|
+
* @returns {Promise<void>} Resolves after the selected file is loaded.
|
|
608
695
|
* @private
|
|
609
696
|
*/
|
|
610
697
|
_loadImageFile(file) {
|
|
611
|
-
if (!
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
698
|
+
if (!this._isSupportedImageFile(file)) {
|
|
699
|
+
const error = new Error('Selected file is not a supported image');
|
|
700
|
+
this._reportError('Selected file is not a supported image', error);
|
|
701
|
+
return Promise.reject(error);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return new Promise((resolve, reject) => {
|
|
705
|
+
const reader = new FileReader();
|
|
706
|
+
reader.onload = (event) => {
|
|
707
|
+
this.loadImage(event.target.result)
|
|
708
|
+
.then(resolve)
|
|
709
|
+
.catch(reject);
|
|
710
|
+
};
|
|
711
|
+
reader.onerror = (event) => {
|
|
712
|
+
const error = new Error('Image file could not be read');
|
|
713
|
+
this._reportError('Image file could not be read', event);
|
|
714
|
+
reject(error);
|
|
715
|
+
};
|
|
716
|
+
reader.readAsDataURL(file);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
_isSupportedImageFile(file) {
|
|
721
|
+
if (!file) return false;
|
|
722
|
+
if (typeof file.type === 'string' && file.type.startsWith('image/')) return true;
|
|
723
|
+
const fileName = String(file.name || '');
|
|
724
|
+
return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
|
|
616
725
|
}
|
|
617
726
|
|
|
618
727
|
/**
|
|
@@ -645,120 +754,118 @@ function ensureFabric() {
|
|
|
645
754
|
*/
|
|
646
755
|
async loadImage(imageBase64, options = {}) {
|
|
647
756
|
if (!this._fabricLoaded) return;
|
|
648
|
-
if (!this.canvas) return;
|
|
757
|
+
if (!this.canvas || this._disposed) return;
|
|
649
758
|
if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
|
|
759
|
+
this._assertIdleForOperation('loadImage', options);
|
|
650
760
|
|
|
761
|
+
this._isLoading = true;
|
|
762
|
+
this._updateUI();
|
|
651
763
|
this._warnOnImageLayoutOptionConflict();
|
|
652
|
-
this.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
764
|
+
const transaction = this._captureLoadImageTransaction();
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const imageElement = await this._createImageElement(imageBase64);
|
|
768
|
+
if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
|
|
769
|
+
|
|
770
|
+
let loadSource = imageBase64;
|
|
771
|
+
if (this.options.downsampleOnLoad) {
|
|
772
|
+
const shouldResize =
|
|
773
|
+
imageElement.naturalWidth > this.options.downsampleMaxWidth ||
|
|
774
|
+
imageElement.naturalHeight > this.options.downsampleMaxHeight;
|
|
775
|
+
if (shouldResize) {
|
|
776
|
+
const ratio = Math.min(
|
|
777
|
+
this.options.downsampleMaxWidth / imageElement.naturalWidth,
|
|
778
|
+
this.options.downsampleMaxHeight / imageElement.naturalHeight
|
|
779
|
+
);
|
|
780
|
+
const targetWidth = Math.round(imageElement.naturalWidth * ratio);
|
|
781
|
+
const targetHeight = Math.round(imageElement.naturalHeight * ratio);
|
|
782
|
+
loadSource = this._resampleImageToDataURL(
|
|
783
|
+
imageElement,
|
|
784
|
+
targetWidth,
|
|
785
|
+
targetHeight,
|
|
786
|
+
this._normalizeQuality(this.options.downsampleQuality),
|
|
787
|
+
imageBase64
|
|
788
|
+
);
|
|
789
|
+
}
|
|
670
790
|
}
|
|
671
|
-
}
|
|
672
791
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
fabricImage.set({ left: 0, top: 0 });
|
|
723
|
-
fabricImage.scale(fitScale);
|
|
724
|
-
this.baseImageScale = fabricImage.scaleX || 1;
|
|
725
|
-
}
|
|
726
|
-
// Put the image onto the canvas
|
|
727
|
-
this.originalImage = fabricImage;
|
|
728
|
-
this.canvas.add(fabricImage);
|
|
729
|
-
this.canvas.sendToBack(fabricImage);
|
|
730
|
-
|
|
731
|
-
// Reset mask placement memory
|
|
732
|
-
this._lastMask = null;
|
|
733
|
-
this._lastMaskInitialLeft = null;
|
|
734
|
-
this._lastMaskInitialTop = null;
|
|
735
|
-
this._lastMaskInitialWidth = null;
|
|
736
|
-
|
|
737
|
-
this.maskCounter = 0;
|
|
738
|
-
this.currentScale = 1;
|
|
739
|
-
this.currentRotation = 0;
|
|
740
|
-
|
|
741
|
-
this._updateInputs();
|
|
742
|
-
this._updateMaskList();
|
|
743
|
-
this.isImageLoadedToCanvas = true;
|
|
744
|
-
this._updateUI();
|
|
745
|
-
this.canvas.renderAll();
|
|
746
|
-
try {
|
|
747
|
-
this._lastSnapshot = this._serializeCanvasState();
|
|
748
|
-
} catch (error) {
|
|
749
|
-
this._reportWarning('loadImage: failed to capture initial canvas snapshot', error);
|
|
750
|
-
}
|
|
792
|
+
const fabricImage = await this._createFabricImageFromURL(loadSource);
|
|
793
|
+
if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
|
|
794
|
+
|
|
795
|
+
this.canvas.discardActiveObject();
|
|
796
|
+
this._hideAllMaskLabels();
|
|
797
|
+
this.canvas.clear();
|
|
798
|
+
this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
|
|
799
|
+
|
|
800
|
+
fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
|
|
801
|
+
this._setPlaceholderVisible(false);
|
|
802
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
803
|
+
|
|
804
|
+
const imageWidth = fabricImage.width;
|
|
805
|
+
const imageHeight = fabricImage.height;
|
|
806
|
+
|
|
807
|
+
const viewport = this._getContainerViewportSize();
|
|
808
|
+
const minWidth = viewport.width;
|
|
809
|
+
const minHeight = viewport.height;
|
|
810
|
+
|
|
811
|
+
if (this.options.fitImageToCanvas) {
|
|
812
|
+
const canvasWidth = Math.max(1, minWidth - 1);
|
|
813
|
+
const canvasHeight = Math.max(1, minHeight - 1);
|
|
814
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
815
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
816
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
817
|
+
fabricImage.scale(fitScale);
|
|
818
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
819
|
+
} else if (this.options.coverImageToCanvas) {
|
|
820
|
+
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
821
|
+
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
822
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
823
|
+
fabricImage.scale(layout.scale);
|
|
824
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
825
|
+
} else if (this.options.expandCanvasToImage) {
|
|
826
|
+
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
827
|
+
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
828
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
829
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
830
|
+
fabricImage.scale(1);
|
|
831
|
+
this.baseImageScale = 1;
|
|
832
|
+
} else {
|
|
833
|
+
const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
|
|
834
|
+
const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
|
|
835
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
836
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
837
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
838
|
+
fabricImage.scale(fitScale);
|
|
839
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
840
|
+
}
|
|
751
841
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
842
|
+
this.originalImage = fabricImage;
|
|
843
|
+
this.canvas.add(fabricImage);
|
|
844
|
+
this.canvas.sendToBack(fabricImage);
|
|
755
845
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
846
|
+
this._clearMaskPlacementMemory();
|
|
847
|
+
if (options.resetMaskCounter !== false) this.maskCounter = 0;
|
|
848
|
+
this.currentScale = 1;
|
|
849
|
+
this.currentRotation = 0;
|
|
850
|
+
|
|
851
|
+
// this._setPlaceholderVisible(false);
|
|
852
|
+
this._updateInputs();
|
|
853
|
+
this._updateMaskList();
|
|
854
|
+
this.isImageLoadedToCanvas = true;
|
|
855
|
+
this._updateUI();
|
|
856
|
+
this.canvas.renderAll();
|
|
857
|
+
this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
|
|
858
|
+
|
|
859
|
+
if (typeof this.onImageLoaded === 'function') {
|
|
860
|
+
this.onImageLoaded();
|
|
861
|
+
}
|
|
862
|
+
} catch (error) {
|
|
863
|
+
await this._rollbackLoadImageTransaction(transaction);
|
|
864
|
+
throw error;
|
|
865
|
+
} finally {
|
|
866
|
+
this._isLoading = false;
|
|
867
|
+
if (!this._disposed && this.canvas) this._updateUI();
|
|
868
|
+
}
|
|
762
869
|
}
|
|
763
870
|
|
|
764
871
|
/**
|
|
@@ -810,24 +917,167 @@ function ensureFabric() {
|
|
|
810
917
|
});
|
|
811
918
|
}
|
|
812
919
|
|
|
920
|
+
_createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
921
|
+
return new Promise((resolve, reject) => {
|
|
922
|
+
const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
|
|
923
|
+
let isSettled = false;
|
|
924
|
+
let timerId;
|
|
925
|
+
const settle = (callback) => {
|
|
926
|
+
if (isSettled) return;
|
|
927
|
+
isSettled = true;
|
|
928
|
+
clearTimeout(timerId);
|
|
929
|
+
callback();
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
timerId = setTimeout(() => {
|
|
933
|
+
settle(() => reject(new Error('Fabric image load timed out')));
|
|
934
|
+
}, safeTimeoutMs);
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
fabric.Image.fromURL(dataUrl, (fabricImage) => {
|
|
938
|
+
settle(() => {
|
|
939
|
+
if (!fabricImage) {
|
|
940
|
+
reject(new Error('Image could not be loaded'));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
resolve(fabricImage);
|
|
944
|
+
});
|
|
945
|
+
}, { crossOrigin: 'anonymous' });
|
|
946
|
+
} catch (error) {
|
|
947
|
+
settle(() => reject(error));
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_getSafeTimeoutMs(timeoutMs) {
|
|
953
|
+
const safeTimeoutMs = Number(timeoutMs);
|
|
954
|
+
return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 30000;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
_captureLoadImageTransaction() {
|
|
958
|
+
return {
|
|
959
|
+
canvasState: this._serializeCanvasState(),
|
|
960
|
+
originalImage: this.originalImage,
|
|
961
|
+
baseImageScale: this.baseImageScale,
|
|
962
|
+
currentScale: this.currentScale,
|
|
963
|
+
currentRotation: this.currentRotation,
|
|
964
|
+
maskCounter: this.maskCounter,
|
|
965
|
+
isImageLoadedToCanvas: this.isImageLoadedToCanvas,
|
|
966
|
+
lastSnapshot: this._lastSnapshot,
|
|
967
|
+
lastMask: this._lastMask,
|
|
968
|
+
lastMaskInitialLeft: this._lastMaskInitialLeft,
|
|
969
|
+
lastMaskInitialTop: this._lastMaskInitialTop,
|
|
970
|
+
lastMaskInitialWidth: this._lastMaskInitialWidth,
|
|
971
|
+
containerOverflow: this.containerElement && this.containerElement.style ? {
|
|
972
|
+
overflow: this.containerElement.style.overflow || '',
|
|
973
|
+
overflowX: this.containerElement.style.overflowX || '',
|
|
974
|
+
overflowY: this.containerElement.style.overflowY || ''
|
|
975
|
+
} : null,
|
|
976
|
+
scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
|
|
977
|
+
scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
|
|
978
|
+
placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
|
|
979
|
+
canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async _rollbackLoadImageTransaction(transaction) {
|
|
984
|
+
if (!transaction || !this.canvas || this._disposed) return;
|
|
985
|
+
let didRestoreCanvasState = false;
|
|
986
|
+
try {
|
|
987
|
+
if (transaction.canvasState) {
|
|
988
|
+
await this.loadFromState(transaction.canvasState);
|
|
989
|
+
didRestoreCanvasState = true;
|
|
990
|
+
}
|
|
991
|
+
} catch (error) {
|
|
992
|
+
this._lastMask = null;
|
|
993
|
+
this._reportError('loadImage rollback failed', error);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
this.baseImageScale = transaction.baseImageScale;
|
|
997
|
+
this.currentScale = transaction.currentScale;
|
|
998
|
+
this.currentRotation = transaction.currentRotation;
|
|
999
|
+
this.maskCounter = transaction.maskCounter;
|
|
1000
|
+
this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
|
|
1001
|
+
this._lastSnapshot = transaction.lastSnapshot;
|
|
1002
|
+
if (didRestoreCanvasState) {
|
|
1003
|
+
this._restoreLastMaskReference(transaction.lastMask);
|
|
1004
|
+
} else {
|
|
1005
|
+
this._lastMask = null;
|
|
1006
|
+
}
|
|
1007
|
+
this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
|
|
1008
|
+
this._lastMaskInitialTop = transaction.lastMaskInitialTop;
|
|
1009
|
+
this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
|
|
1010
|
+
this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
|
|
1011
|
+
this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
|
|
1012
|
+
if (this.containerElement) {
|
|
1013
|
+
this.containerElement.scrollLeft = transaction.scrollLeft;
|
|
1014
|
+
this.containerElement.scrollTop = transaction.scrollTop;
|
|
1015
|
+
this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
|
|
1016
|
+
}
|
|
1017
|
+
this._updateInputs();
|
|
1018
|
+
this._updateMaskList();
|
|
1019
|
+
this._updateUI();
|
|
1020
|
+
if (this.canvas) this.canvas.renderAll();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
_restoreLastMaskReference(previousLastMask) {
|
|
1024
|
+
if (!this.canvas) {
|
|
1025
|
+
this._lastMask = null;
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1030
|
+
const previousMaskId = previousLastMask && previousLastMask.maskId;
|
|
1031
|
+
this._lastMask = masks.find(mask => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
|
|
1032
|
+
if (!this._lastMask) {
|
|
1033
|
+
this._lastMaskInitialLeft = null;
|
|
1034
|
+
this._lastMaskInitialTop = null;
|
|
1035
|
+
this._lastMaskInitialWidth = null;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
813
1039
|
/**
|
|
814
|
-
* Resamples the given image element to a new width and height and returns the result as a
|
|
1040
|
+
* Resamples the given image element to a new width and height and returns the result as a data URL.
|
|
815
1041
|
*
|
|
816
1042
|
* @param {HTMLImageElement} imageElement - The image element to resample.
|
|
817
1043
|
* @param {number} targetWidth - Target width (in pixels) for the resampled image.
|
|
818
1044
|
* @param {number} targetHeight - Target height (in pixels) for the resampled image.
|
|
819
|
-
* @param {number} [quality=0.92] -
|
|
820
|
-
* @
|
|
1045
|
+
* @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1046
|
+
* @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
|
|
1047
|
+
* @returns {string} A data URL representing the resampled image.
|
|
821
1048
|
* @private
|
|
822
1049
|
*/
|
|
823
|
-
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
|
|
1050
|
+
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
|
|
824
1051
|
const offscreenCanvas = document.createElement('canvas');
|
|
825
1052
|
offscreenCanvas.width = targetWidth;
|
|
826
1053
|
offscreenCanvas.height = targetHeight;
|
|
827
1054
|
const context = offscreenCanvas.getContext('2d');
|
|
828
1055
|
if (!context) throw new Error('2D canvas context is unavailable');
|
|
829
1056
|
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
830
|
-
return offscreenCanvas.toDataURL(
|
|
1057
|
+
return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
_getDataUrlMimeType(dataUrl) {
|
|
1061
|
+
const match = String(dataUrl || '').match(/^data:([^;,]+)[;,]/i);
|
|
1062
|
+
return match ? match[1].toLowerCase() : '';
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
_getDownsampleMimeType(sourceDataUrl) {
|
|
1066
|
+
if (this.options.downsampleMimeType) {
|
|
1067
|
+
const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
|
|
1068
|
+
return `image/${requestedFormat}`;
|
|
1069
|
+
}
|
|
1070
|
+
const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
|
|
1071
|
+
if (this.options.preserveSourceFormat !== false && (sourceMimeType === 'image/png' || sourceMimeType === 'image/webp')) {
|
|
1072
|
+
return sourceMimeType;
|
|
1073
|
+
}
|
|
1074
|
+
return 'image/jpeg';
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
_captureCanvasStateOrThrow(context) {
|
|
1078
|
+
const snapshot = this._serializeCanvasState();
|
|
1079
|
+
if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
|
|
1080
|
+
return snapshot;
|
|
831
1081
|
}
|
|
832
1082
|
|
|
833
1083
|
/**
|
|
@@ -849,7 +1099,6 @@ function ensureFabric() {
|
|
|
849
1099
|
if (this.canvasElement) {
|
|
850
1100
|
this.canvasElement.style.width = integerWidth + 'px';
|
|
851
1101
|
this.canvasElement.style.height = integerHeight + 'px';
|
|
852
|
-
this.canvasElement.style.maxWidth = 'none';
|
|
853
1102
|
}
|
|
854
1103
|
}
|
|
855
1104
|
|
|
@@ -868,8 +1117,14 @@ function ensureFabric() {
|
|
|
868
1117
|
};
|
|
869
1118
|
}
|
|
870
1119
|
|
|
871
|
-
|
|
872
|
-
|
|
1120
|
+
const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
|
|
1121
|
+
const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
|
|
1122
|
+
let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
|
|
1123
|
+
let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
|
|
1124
|
+
|
|
1125
|
+
if (measuredWidth > 0 && measuredHeight > 0) {
|
|
1126
|
+
this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
|
|
1127
|
+
}
|
|
873
1128
|
|
|
874
1129
|
if (this._hasFixedContainerScrollbars()) {
|
|
875
1130
|
return { width, height };
|
|
@@ -1106,7 +1361,11 @@ function ensureFabric() {
|
|
|
1106
1361
|
maskStyleBackups.push(backup);
|
|
1107
1362
|
mask.set(stylePatch);
|
|
1108
1363
|
});
|
|
1109
|
-
|
|
1364
|
+
const result = callback();
|
|
1365
|
+
if (result && typeof result.then === 'function') {
|
|
1366
|
+
throw new Error('_withNormalizedMaskStyles callback must be synchronous');
|
|
1367
|
+
}
|
|
1368
|
+
return result;
|
|
1110
1369
|
} finally {
|
|
1111
1370
|
maskStyleBackups.forEach(backup => {
|
|
1112
1371
|
try {
|
|
@@ -1178,9 +1437,15 @@ function ensureFabric() {
|
|
|
1178
1437
|
* @returns {number} A finite quality value between 0 and 1.
|
|
1179
1438
|
* @private
|
|
1180
1439
|
*/
|
|
1181
|
-
_normalizeQuality(quality) {
|
|
1440
|
+
_normalizeQuality(quality, fallback = undefined) {
|
|
1441
|
+
const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
|
|
1442
|
+
const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
|
|
1443
|
+
const safeFallback = Number.isFinite(numericFallback)
|
|
1444
|
+
? Math.max(0, Math.min(1, numericFallback))
|
|
1445
|
+
: 0.92;
|
|
1446
|
+
if (quality == null) return safeFallback;
|
|
1182
1447
|
const numericQuality = Number(quality);
|
|
1183
|
-
if (!Number.isFinite(numericQuality)) return
|
|
1448
|
+
if (!Number.isFinite(numericQuality)) return safeFallback;
|
|
1184
1449
|
return Math.max(0, Math.min(1, numericQuality));
|
|
1185
1450
|
}
|
|
1186
1451
|
|
|
@@ -1235,65 +1500,74 @@ function ensureFabric() {
|
|
|
1235
1500
|
};
|
|
1236
1501
|
}
|
|
1237
1502
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
}
|
|
1503
|
+
_hasFractionalCanvasEdge(value) {
|
|
1504
|
+
const numericValue = Number(value);
|
|
1505
|
+
if (!Number.isFinite(numericValue)) return false;
|
|
1506
|
+
return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
_getPartialExportEdges(bounds) {
|
|
1510
|
+
if (!bounds) return null;
|
|
1511
|
+
const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
|
|
1512
|
+
const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
|
|
1513
|
+
if (!isAxisAligned) return null;
|
|
1514
|
+
|
|
1515
|
+
return {
|
|
1516
|
+
left: this._hasFractionalCanvasEdge(bounds.left),
|
|
1517
|
+
top: this._hasFractionalCanvasEdge(bounds.top),
|
|
1518
|
+
right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
|
|
1519
|
+
bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async _sealPartialTransparentEdges(dataUrl, edges) {
|
|
1524
|
+
if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
|
|
1525
|
+
|
|
1526
|
+
const imageElement = await this._createImageElement(dataUrl);
|
|
1527
|
+
const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
|
|
1528
|
+
const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
|
|
1529
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
1530
|
+
offscreenCanvas.width = width;
|
|
1531
|
+
offscreenCanvas.height = height;
|
|
1532
|
+
const context = offscreenCanvas.getContext('2d');
|
|
1533
|
+
if (!context) throw new Error('2D canvas context is unavailable');
|
|
1534
|
+
context.drawImage(imageElement, 0, 0, width, height);
|
|
1535
|
+
|
|
1536
|
+
const imageData = context.getImageData(0, 0, width, height);
|
|
1537
|
+
const pixels = imageData.data;
|
|
1538
|
+
const sealPixel = (x, y, fallbackX, fallbackY) => {
|
|
1539
|
+
const index = (y * width + x) * 4;
|
|
1540
|
+
const fallbackIndex = (fallbackY * width + fallbackX) * 4;
|
|
1541
|
+
if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
|
|
1542
|
+
pixels[index] = pixels[fallbackIndex];
|
|
1543
|
+
pixels[index + 1] = pixels[fallbackIndex + 1];
|
|
1544
|
+
pixels[index + 2] = pixels[fallbackIndex + 2];
|
|
1545
|
+
pixels[index + 3] = pixels[fallbackIndex + 3];
|
|
1546
|
+
}
|
|
1547
|
+
if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
|
|
1548
|
+
pixels[index + 3] = 255;
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
if (edges.left && width > 1) {
|
|
1553
|
+
for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
|
|
1554
|
+
}
|
|
1555
|
+
if (edges.right && width > 1) {
|
|
1556
|
+
for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
|
|
1557
|
+
}
|
|
1558
|
+
if (edges.top && height > 1) {
|
|
1559
|
+
for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
|
|
1560
|
+
}
|
|
1561
|
+
if (edges.bottom && height > 1) {
|
|
1562
|
+
for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
context.putImageData(imageData, 0, 0);
|
|
1566
|
+
return offscreenCanvas.toDataURL('image/png');
|
|
1293
1567
|
}
|
|
1294
1568
|
|
|
1295
1569
|
/**
|
|
1296
|
-
* Exports
|
|
1570
|
+
* Exports a source region directly through Fabric's region export options.
|
|
1297
1571
|
*
|
|
1298
1572
|
* @param {Object} region - Canvas source region and export options.
|
|
1299
1573
|
* @param {number} region.sourceX - Source region x coordinate.
|
|
@@ -1303,18 +1577,49 @@ function ensureFabric() {
|
|
|
1303
1577
|
* @param {number} [region.multiplier=1] - Export multiplier.
|
|
1304
1578
|
* @param {number} [region.quality=0.92] - Output image quality for lossy formats.
|
|
1305
1579
|
* @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
|
|
1580
|
+
* @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
|
|
1306
1581
|
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1307
1582
|
* @private
|
|
1308
1583
|
*/
|
|
1309
|
-
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
|
|
1584
|
+
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
|
|
1310
1585
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1586
|
+
const safeFormat = this._normalizeImageFormat(format);
|
|
1587
|
+
const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
|
|
1588
|
+
let regionDataUrl = this.canvas.toDataURL({
|
|
1589
|
+
format: exportFormat,
|
|
1313
1590
|
quality,
|
|
1314
|
-
multiplier: safeMultiplier
|
|
1591
|
+
multiplier: safeMultiplier,
|
|
1592
|
+
left: sourceX,
|
|
1593
|
+
top: sourceY,
|
|
1594
|
+
width: sourceWidth,
|
|
1595
|
+
height: sourceHeight
|
|
1315
1596
|
});
|
|
1316
1597
|
|
|
1317
|
-
|
|
1598
|
+
regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
|
|
1599
|
+
if (safeFormat !== 'jpeg') return regionDataUrl;
|
|
1600
|
+
return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
|
|
1604
|
+
const imageElement = await this._createImageElement(dataUrl);
|
|
1605
|
+
const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
|
|
1606
|
+
const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
|
|
1607
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
1608
|
+
offscreenCanvas.width = width;
|
|
1609
|
+
offscreenCanvas.height = height;
|
|
1610
|
+
const context = offscreenCanvas.getContext('2d');
|
|
1611
|
+
if (!context) throw new Error('2D canvas context is unavailable');
|
|
1612
|
+
context.fillStyle = this._getJpegBackgroundColor();
|
|
1613
|
+
context.fillRect(0, 0, width, height);
|
|
1614
|
+
context.drawImage(imageElement, 0, 0, width, height);
|
|
1615
|
+
return offscreenCanvas.toDataURL('image/jpeg', this._normalizeQuality(quality));
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
_getJpegBackgroundColor() {
|
|
1619
|
+
const backgroundColor = String(this.options.backgroundColor || '').trim();
|
|
1620
|
+
if (!backgroundColor || backgroundColor === 'transparent') return '#ffffff';
|
|
1621
|
+
if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return '#ffffff';
|
|
1622
|
+
return backgroundColor;
|
|
1318
1623
|
}
|
|
1319
1624
|
|
|
1320
1625
|
/**
|
|
@@ -1328,12 +1633,41 @@ function ensureFabric() {
|
|
|
1328
1633
|
_getObjectTopLeftPoint(fabricObject) {
|
|
1329
1634
|
if (!fabricObject) return { x: 0, y: 0 };
|
|
1330
1635
|
fabricObject.setCoords();
|
|
1331
|
-
const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
|
|
1332
|
-
if (coords && coords.length) return coords[0];
|
|
1333
1636
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1334
1637
|
return { x: boundingRect.left, y: boundingRect.top };
|
|
1335
1638
|
}
|
|
1336
1639
|
|
|
1640
|
+
_getObjectCoordinateTopLeftPoint(fabricObject) {
|
|
1641
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
1642
|
+
fabricObject.setCoords();
|
|
1643
|
+
const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
|
|
1644
|
+
if (coords && coords.length) return coords[0];
|
|
1645
|
+
return this._getObjectTopLeftPoint(fabricObject);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
_getObjectOriginPoint(fabricObject, originX, originY) {
|
|
1649
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
1650
|
+
if (typeof fabricObject.getPointByOrigin === 'function') {
|
|
1651
|
+
return fabricObject.getPointByOrigin(originX, originY);
|
|
1652
|
+
}
|
|
1653
|
+
return this._getObjectTopLeftPoint(fabricObject);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
_translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
|
|
1657
|
+
if (!fabricObject) return;
|
|
1658
|
+
if (typeof fabricObject.getCenterPoint === 'function' && typeof fabricObject.setPositionByOrigin === 'function') {
|
|
1659
|
+
const center = fabricObject.getCenterPoint();
|
|
1660
|
+
const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
|
|
1661
|
+
fabricObject.setPositionByOrigin(nextCenter, 'center', 'center');
|
|
1662
|
+
} else {
|
|
1663
|
+
fabricObject.set({
|
|
1664
|
+
left: (fabricObject.left || 0) + deltaX,
|
|
1665
|
+
top: (fabricObject.top || 0) + deltaY
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
fabricObject.setCoords();
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1337
1671
|
/**
|
|
1338
1672
|
* Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
|
|
1339
1673
|
*
|
|
@@ -1402,8 +1736,10 @@ function ensureFabric() {
|
|
|
1402
1736
|
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1403
1737
|
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
|
|
1404
1738
|
try {
|
|
1405
|
-
|
|
1406
|
-
|
|
1739
|
+
const currentWidth = this.canvas.getWidth();
|
|
1740
|
+
const currentHeight = this.canvas.getHeight();
|
|
1741
|
+
let requiredWidth = currentWidth;
|
|
1742
|
+
let requiredHeight = currentHeight;
|
|
1407
1743
|
fabricObjects.forEach(fabricObject => {
|
|
1408
1744
|
if (!fabricObject) return;
|
|
1409
1745
|
if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
|
|
@@ -1411,11 +1747,23 @@ function ensureFabric() {
|
|
|
1411
1747
|
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1412
1748
|
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1413
1749
|
});
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
if (
|
|
1750
|
+
const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
|
|
1751
|
+
|
|
1752
|
+
let minWidth = 0;
|
|
1753
|
+
let minHeight = 0;
|
|
1754
|
+
if (shouldUseScrollSafeViewport) {
|
|
1755
|
+
const viewport = this._getContainerViewportSize();
|
|
1756
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
1757
|
+
|
|
1758
|
+
minWidth = Math.max(1, viewport.width - safetyMargin);
|
|
1759
|
+
minHeight = Math.max(1, viewport.height - safetyMargin);
|
|
1760
|
+
} else if (this.containerElement) {
|
|
1761
|
+
minWidth = Math.floor(this.containerElement.clientWidth || 0);
|
|
1762
|
+
minHeight = Math.floor(this.containerElement.clientHeight || 0);
|
|
1763
|
+
}
|
|
1764
|
+
const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
|
|
1765
|
+
const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
|
|
1766
|
+
if (newWidth !== currentWidth || newHeight !== currentHeight) {
|
|
1419
1767
|
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1420
1768
|
}
|
|
1421
1769
|
} catch (error) {
|
|
@@ -1443,7 +1791,131 @@ function ensureFabric() {
|
|
|
1443
1791
|
* @public
|
|
1444
1792
|
*/
|
|
1445
1793
|
scaleImage(factor, options = {}) {
|
|
1446
|
-
|
|
1794
|
+
try {
|
|
1795
|
+
this._assertCanQueueAnimation('scaleImage', options);
|
|
1796
|
+
} catch (error) {
|
|
1797
|
+
return Promise.reject(error);
|
|
1798
|
+
}
|
|
1799
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options))
|
|
1800
|
+
.finally(() => {
|
|
1801
|
+
if (!this._disposed && this.canvas) this._updateUI();
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
_getInternalOperationToken(options) {
|
|
1806
|
+
return options && options[INTERNAL_OPERATION_TOKEN];
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
_isOwnInternalOperation(options) {
|
|
1810
|
+
const token = this._getInternalOperationToken(options);
|
|
1811
|
+
return !!token && token === this._activeOperationToken;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
_beginBusyOperation(operationName) {
|
|
1815
|
+
const token = Symbol(operationName);
|
|
1816
|
+
this._activeOperationName = operationName;
|
|
1817
|
+
this._activeOperationToken = token;
|
|
1818
|
+
this._updateUI();
|
|
1819
|
+
return token;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
_endBusyOperation(token) {
|
|
1823
|
+
if (token && token === this._activeOperationToken) {
|
|
1824
|
+
this._activeOperationName = null;
|
|
1825
|
+
this._activeOperationToken = null;
|
|
1826
|
+
this._updateUI();
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
_withInternalOperationOptions(token, options = {}) {
|
|
1831
|
+
return {
|
|
1832
|
+
...options,
|
|
1833
|
+
[INTERNAL_OPERATION_TOKEN]: token
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
_assertEditorAvailable(operationName) {
|
|
1838
|
+
if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
_assertIdleForOperation(operationName, options = {}) {
|
|
1842
|
+
this._assertEditorAvailable(operationName);
|
|
1843
|
+
const isOwnInternalOperation = this._isOwnInternalOperation(options);
|
|
1844
|
+
if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
|
|
1845
|
+
throw new Error(`${operationName} cannot run while an animation is running`);
|
|
1846
|
+
}
|
|
1847
|
+
if (this._isLoading && !isOwnInternalOperation) {
|
|
1848
|
+
throw new Error(`${operationName} cannot run while an image is loading`);
|
|
1849
|
+
}
|
|
1850
|
+
if (this._activeOperationToken && !isOwnInternalOperation) {
|
|
1851
|
+
throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
_assertCanQueueAnimation(operationName, options = {}) {
|
|
1856
|
+
this._assertEditorAvailable(operationName);
|
|
1857
|
+
if (this._isLoading && !this._isOwnInternalOperation(options)) {
|
|
1858
|
+
throw new Error(`${operationName} cannot run while an image is loading`);
|
|
1859
|
+
}
|
|
1860
|
+
if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
|
|
1861
|
+
throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
_canMutateNow(operationName, options = {}) {
|
|
1866
|
+
try {
|
|
1867
|
+
this._assertIdleForOperation(operationName, options);
|
|
1868
|
+
return true;
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
this._reportError(`${operationName} blocked`, error);
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
_rejectActiveAnimations(reason) {
|
|
1876
|
+
const error = reason instanceof Error ? reason : new Error(String(reason || 'Animation cancelled'));
|
|
1877
|
+
this._activeAnimationRejectors.forEach(reject => {
|
|
1878
|
+
try { reject(error); } catch (rejectError) { void rejectError; }
|
|
1879
|
+
});
|
|
1880
|
+
this._activeAnimationRejectors.clear();
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
_animateFabricProperty(fabricObject, property, value) {
|
|
1884
|
+
return new Promise((resolve, reject) => {
|
|
1885
|
+
if (this._disposed || !this.canvas || !fabricObject) {
|
|
1886
|
+
reject(new Error('Animation cannot start after editor disposal'));
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
let isSettled = false;
|
|
1891
|
+
const duration = Math.max(0, Number(this.options.animationDuration) || 0);
|
|
1892
|
+
const timeoutMs = Math.max(1000, duration + 1000);
|
|
1893
|
+
let timerId;
|
|
1894
|
+
const settle = (callback) => {
|
|
1895
|
+
if (isSettled) return;
|
|
1896
|
+
isSettled = true;
|
|
1897
|
+
clearTimeout(timerId);
|
|
1898
|
+
this._activeAnimationRejectors.delete(reject);
|
|
1899
|
+
callback();
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
this._activeAnimationRejectors.add(reject);
|
|
1903
|
+
timerId = setTimeout(() => {
|
|
1904
|
+
settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
|
|
1905
|
+
}, timeoutMs);
|
|
1906
|
+
|
|
1907
|
+
try {
|
|
1908
|
+
fabricObject.animate(property, value, {
|
|
1909
|
+
duration,
|
|
1910
|
+
onChange: () => {
|
|
1911
|
+
if (!this._disposed && this.canvas) this.canvas.renderAll();
|
|
1912
|
+
},
|
|
1913
|
+
onComplete: () => settle(resolve)
|
|
1914
|
+
});
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
settle(() => reject(error));
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1447
1919
|
}
|
|
1448
1920
|
|
|
1449
1921
|
/**
|
|
@@ -1453,37 +1925,29 @@ function ensureFabric() {
|
|
|
1453
1925
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
1454
1926
|
* @private
|
|
1455
1927
|
*/
|
|
1456
|
-
_scaleImageImpl(factor, options = {}) {
|
|
1457
|
-
if (!this.originalImage
|
|
1458
|
-
if (this.isAnimating) return
|
|
1928
|
+
async _scaleImageImpl(factor, options = {}) {
|
|
1929
|
+
if (!this.originalImage || this._disposed) return;
|
|
1930
|
+
if (this.isAnimating) return;
|
|
1459
1931
|
const saveHistory = options.saveHistory !== false;
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1932
|
+
let didStartAnimation = false;
|
|
1933
|
+
try {
|
|
1934
|
+
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
1935
|
+
this.currentScale = factor;
|
|
1936
|
+
this.isAnimating = true;
|
|
1937
|
+
didStartAnimation = true;
|
|
1938
|
+
this._updateUI();
|
|
1464
1939
|
|
|
1465
|
-
|
|
1940
|
+
const targetScale = this.baseImageScale * factor;
|
|
1466
1941
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
|
|
1942
|
+
const topLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
1943
|
+
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
|
|
1470
1944
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
});
|
|
1477
|
-
});
|
|
1478
|
-
const scaleYAnimation = new Promise((resolve) => {
|
|
1479
|
-
this.originalImage.animate('scaleY', targetScale, {
|
|
1480
|
-
duration: this.options.animationDuration,
|
|
1481
|
-
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
1482
|
-
onComplete: resolve
|
|
1483
|
-
});
|
|
1484
|
-
});
|
|
1945
|
+
await Promise.all([
|
|
1946
|
+
this._animateFabricProperty(this.originalImage, 'scaleX', targetScale),
|
|
1947
|
+
this._animateFabricProperty(this.originalImage, 'scaleY', targetScale)
|
|
1948
|
+
]);
|
|
1949
|
+
if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during scale animation');
|
|
1485
1950
|
|
|
1486
|
-
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1487
1951
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1488
1952
|
this.originalImage.setCoords();
|
|
1489
1953
|
|
|
@@ -1493,17 +1957,17 @@ function ensureFabric() {
|
|
|
1493
1957
|
|
|
1494
1958
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1495
1959
|
|
|
1496
|
-
// Sync mask labels
|
|
1497
1960
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
1498
1961
|
|
|
1499
|
-
this.isAnimating = false;
|
|
1500
1962
|
this._updateInputs();
|
|
1501
|
-
this._updateUI();
|
|
1502
1963
|
if (saveHistory) this.saveState();
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1964
|
+
} finally {
|
|
1965
|
+
if (didStartAnimation) {
|
|
1966
|
+
this.isAnimating = false;
|
|
1967
|
+
this._updateInputs();
|
|
1968
|
+
this._updateUI();
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1507
1971
|
}
|
|
1508
1972
|
|
|
1509
1973
|
/**
|
|
@@ -1514,7 +1978,15 @@ function ensureFabric() {
|
|
|
1514
1978
|
* @public
|
|
1515
1979
|
*/
|
|
1516
1980
|
rotateImage(degrees, options = {}) {
|
|
1517
|
-
|
|
1981
|
+
try {
|
|
1982
|
+
this._assertCanQueueAnimation('rotateImage', options);
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
return Promise.reject(error);
|
|
1985
|
+
}
|
|
1986
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options))
|
|
1987
|
+
.finally(() => {
|
|
1988
|
+
if (!this._disposed && this.canvas) this._updateUI();
|
|
1989
|
+
});
|
|
1518
1990
|
}
|
|
1519
1991
|
|
|
1520
1992
|
/**
|
|
@@ -1524,27 +1996,29 @@ function ensureFabric() {
|
|
|
1524
1996
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
1525
1997
|
* @private
|
|
1526
1998
|
*/
|
|
1527
|
-
_rotateImageImpl(degrees, options = {}) {
|
|
1528
|
-
if (!this.originalImage
|
|
1529
|
-
if (this.isAnimating) return
|
|
1530
|
-
if (isNaN(degrees)) return
|
|
1999
|
+
async _rotateImageImpl(degrees, options = {}) {
|
|
2000
|
+
if (!this.originalImage || this._disposed) return;
|
|
2001
|
+
if (this.isAnimating) return;
|
|
2002
|
+
if (isNaN(degrees)) return;
|
|
1531
2003
|
const saveHistory = options.saveHistory !== false;
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
2004
|
+
const image = this.originalImage;
|
|
2005
|
+
const previousOriginX = image.originX || 'left';
|
|
2006
|
+
const previousOriginY = image.originY || 'top';
|
|
2007
|
+
const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
|
|
2008
|
+
let didStartAnimation = false;
|
|
2009
|
+
let didCompleteRotation = false;
|
|
2010
|
+
try {
|
|
2011
|
+
this.currentRotation = degrees;
|
|
2012
|
+
this.isAnimating = true;
|
|
2013
|
+
didStartAnimation = true;
|
|
2014
|
+
this._updateUI();
|
|
1535
2015
|
|
|
1536
|
-
|
|
1537
|
-
|
|
2016
|
+
const center = image.getCenterPoint();
|
|
2017
|
+
this._setObjectOriginKeepingPosition(image, 'center', 'center', center);
|
|
1538
2018
|
|
|
1539
|
-
|
|
1540
|
-
this.originalImage
|
|
1541
|
-
duration: this.options.animationDuration,
|
|
1542
|
-
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
1543
|
-
onComplete: resolve
|
|
1544
|
-
});
|
|
1545
|
-
});
|
|
2019
|
+
await this._animateFabricProperty(image, 'angle', degrees);
|
|
2020
|
+
if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during rotation animation');
|
|
1546
2021
|
|
|
1547
|
-
return rotationAnimation.then(() => {
|
|
1548
2022
|
this.originalImage.set('angle', degrees);
|
|
1549
2023
|
this.originalImage.setCoords();
|
|
1550
2024
|
|
|
@@ -1554,20 +2028,24 @@ function ensureFabric() {
|
|
|
1554
2028
|
|
|
1555
2029
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1556
2030
|
|
|
1557
|
-
const newTopLeft = this.
|
|
2031
|
+
const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
|
|
1558
2032
|
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
|
|
1559
2033
|
|
|
1560
|
-
// Sync mask labels
|
|
1561
2034
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
1562
2035
|
|
|
1563
|
-
this.isAnimating = false;
|
|
1564
2036
|
this._updateInputs();
|
|
1565
|
-
this._updateUI();
|
|
1566
2037
|
if (saveHistory) this.saveState();
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
this.
|
|
1570
|
-
|
|
2038
|
+
didCompleteRotation = true;
|
|
2039
|
+
} finally {
|
|
2040
|
+
if (!didCompleteRotation && !this._disposed && image) {
|
|
2041
|
+
this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
|
|
2042
|
+
}
|
|
2043
|
+
if (didStartAnimation) {
|
|
2044
|
+
this.isAnimating = false;
|
|
2045
|
+
this._updateInputs();
|
|
2046
|
+
this._updateUI();
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
1571
2049
|
}
|
|
1572
2050
|
|
|
1573
2051
|
/**
|
|
@@ -1578,15 +2056,23 @@ function ensureFabric() {
|
|
|
1578
2056
|
*/
|
|
1579
2057
|
resetImageTransform() {
|
|
1580
2058
|
if (!this.originalImage) return Promise.resolve();
|
|
2059
|
+
try {
|
|
2060
|
+
this._assertCanQueueAnimation('resetImageTransform');
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
return Promise.reject(error);
|
|
2063
|
+
}
|
|
1581
2064
|
|
|
1582
2065
|
return this.animationQueue.add(async () => {
|
|
1583
|
-
const before = this._lastSnapshot || this.
|
|
2066
|
+
const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
|
|
1584
2067
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1585
2068
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1586
|
-
const after = this.
|
|
2069
|
+
const after = this._captureCanvasStateOrThrow('resetImageTransform');
|
|
1587
2070
|
this._pushStateTransition(before, after);
|
|
2071
|
+
}).finally(() => {
|
|
2072
|
+
if (!this._disposed && this.canvas) this._updateUI();
|
|
1588
2073
|
}).catch(error => {
|
|
1589
2074
|
this._reportError('resetImageTransform() failed', error);
|
|
2075
|
+
throw error;
|
|
1590
2076
|
});
|
|
1591
2077
|
}
|
|
1592
2078
|
|
|
@@ -1608,17 +2094,35 @@ function ensureFabric() {
|
|
|
1608
2094
|
* @public
|
|
1609
2095
|
*/
|
|
1610
2096
|
loadFromState(serializedState) {
|
|
1611
|
-
if (!serializedState || !this.canvas) return Promise.resolve();
|
|
2097
|
+
if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
|
|
2098
|
+
if (this._cropMode || this._cropRect) {
|
|
2099
|
+
this._removeCropRect();
|
|
2100
|
+
this._restoreCropObjectState();
|
|
2101
|
+
this._cropMode = false;
|
|
2102
|
+
if (this._prevSelectionSetting !== undefined && this.canvas) {
|
|
2103
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2104
|
+
}
|
|
2105
|
+
this._prevSelectionSetting = undefined;
|
|
2106
|
+
}
|
|
1612
2107
|
|
|
1613
|
-
return new Promise((resolve) => {
|
|
2108
|
+
return new Promise((resolve, reject) => {
|
|
1614
2109
|
try {
|
|
1615
2110
|
const state = (typeof serializedState === 'string')
|
|
1616
2111
|
? JSON.parse(serializedState)
|
|
1617
2112
|
: serializedState;
|
|
1618
2113
|
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1619
2114
|
|
|
1620
|
-
this.canvas.loadFromJSON(state, () => {
|
|
2115
|
+
this.canvas.loadFromJSON(state, async () => {
|
|
1621
2116
|
try {
|
|
2117
|
+
if (this._disposed || !this.canvas) {
|
|
2118
|
+
reject(new Error('Editor was disposed while loading state'));
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
await this._waitForFabricImagesReady(this.canvas.getObjects());
|
|
2122
|
+
if (this._disposed || !this.canvas) {
|
|
2123
|
+
reject(new Error('Editor was disposed while loading state'));
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
1622
2126
|
this._hideAllMaskLabels();
|
|
1623
2127
|
const canvasObjects = this.canvas.getObjects();
|
|
1624
2128
|
this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
|
|
@@ -1677,16 +2181,56 @@ function ensureFabric() {
|
|
|
1677
2181
|
this._updatePlaceholderStatus();
|
|
1678
2182
|
this._lastSnapshot = this._serializeCanvasState();
|
|
1679
2183
|
this._updateUI();
|
|
2184
|
+
resolve();
|
|
1680
2185
|
} catch (callbackError) {
|
|
1681
2186
|
this._reportError('loadFromState() failed', callbackError);
|
|
1682
|
-
|
|
1683
|
-
resolve();
|
|
2187
|
+
reject(callbackError);
|
|
1684
2188
|
}
|
|
1685
2189
|
});
|
|
1686
2190
|
|
|
1687
2191
|
} catch (error) {
|
|
1688
2192
|
this._reportError('loadFromState() failed', error);
|
|
1689
|
-
|
|
2193
|
+
reject(error);
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
async _waitForFabricImagesReady(canvasObjects) {
|
|
2199
|
+
const imageObjects = (canvasObjects || []).filter(object => object && object.type === 'image');
|
|
2200
|
+
await Promise.all(imageObjects.map(object => this._waitForImageElementReady(
|
|
2201
|
+
typeof object.getElement === 'function' ? object.getElement() : object._element
|
|
2202
|
+
)));
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
_waitForImageElementReady(imageElement) {
|
|
2206
|
+
if (!imageElement) return Promise.resolve();
|
|
2207
|
+
if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
|
|
2208
|
+
return new Promise((resolve, reject) => {
|
|
2209
|
+
let isSettled = false;
|
|
2210
|
+
const timerId = setTimeout(() => {
|
|
2211
|
+
settle(() => reject(new Error('Image load timed out while restoring state')));
|
|
2212
|
+
}, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
|
|
2213
|
+
const settle = (callback) => {
|
|
2214
|
+
if (isSettled) return;
|
|
2215
|
+
isSettled = true;
|
|
2216
|
+
clearTimeout(timerId);
|
|
2217
|
+
if (typeof imageElement.removeEventListener === 'function') {
|
|
2218
|
+
imageElement.removeEventListener('load', handleLoad);
|
|
2219
|
+
imageElement.removeEventListener('error', handleError);
|
|
2220
|
+
} else {
|
|
2221
|
+
imageElement.onload = null;
|
|
2222
|
+
imageElement.onerror = null;
|
|
2223
|
+
}
|
|
2224
|
+
callback();
|
|
2225
|
+
};
|
|
2226
|
+
const handleLoad = () => settle(resolve);
|
|
2227
|
+
const handleError = (error) => settle(() => reject(error));
|
|
2228
|
+
if (typeof imageElement.addEventListener === 'function') {
|
|
2229
|
+
imageElement.addEventListener('load', handleLoad, { once: true });
|
|
2230
|
+
imageElement.addEventListener('error', handleError, { once: true });
|
|
2231
|
+
} else {
|
|
2232
|
+
imageElement.onload = handleLoad;
|
|
2233
|
+
imageElement.onerror = handleError;
|
|
1690
2234
|
}
|
|
1691
2235
|
});
|
|
1692
2236
|
}
|
|
@@ -1702,10 +2246,9 @@ function ensureFabric() {
|
|
|
1702
2246
|
*/
|
|
1703
2247
|
saveState() {
|
|
1704
2248
|
if (!this.canvas) return;
|
|
1705
|
-
const activeObject = this.canvas.getActiveObject();
|
|
1706
2249
|
|
|
1707
2250
|
try {
|
|
1708
|
-
const after = this.
|
|
2251
|
+
const after = this._captureCanvasStateOrThrow('saveState');
|
|
1709
2252
|
const before = this._lastSnapshot || after;
|
|
1710
2253
|
if (after === before) return;
|
|
1711
2254
|
let executedOnce = false;
|
|
@@ -1726,9 +2269,6 @@ function ensureFabric() {
|
|
|
1726
2269
|
} catch (error) {
|
|
1727
2270
|
this._reportWarning('saveState: failed to save canvas snapshot', error);
|
|
1728
2271
|
} finally {
|
|
1729
|
-
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1730
|
-
this._handleSelectionChanged([activeObject]);
|
|
1731
|
-
}
|
|
1732
2272
|
this._updateUI();
|
|
1733
2273
|
}
|
|
1734
2274
|
}
|
|
@@ -1745,7 +2285,10 @@ function ensureFabric() {
|
|
|
1745
2285
|
* @private
|
|
1746
2286
|
*/
|
|
1747
2287
|
_pushStateTransition(before, after) {
|
|
1748
|
-
if (!before || !after)
|
|
2288
|
+
if (!before || !after) {
|
|
2289
|
+
this._reportWarning('History transition skipped because a canvas snapshot is unavailable');
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
1749
2292
|
if (before === after) return;
|
|
1750
2293
|
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1751
2294
|
|
|
@@ -1767,7 +2310,10 @@ function ensureFabric() {
|
|
|
1767
2310
|
undo() {
|
|
1768
2311
|
return this.historyManager.undo()
|
|
1769
2312
|
.then(() => { this._updateUI(); })
|
|
1770
|
-
.catch(error => {
|
|
2313
|
+
.catch(error => {
|
|
2314
|
+
this._reportError('undo failed', error);
|
|
2315
|
+
throw error;
|
|
2316
|
+
});
|
|
1771
2317
|
}
|
|
1772
2318
|
|
|
1773
2319
|
/**
|
|
@@ -1779,7 +2325,10 @@ function ensureFabric() {
|
|
|
1779
2325
|
redo() {
|
|
1780
2326
|
return this.historyManager.redo()
|
|
1781
2327
|
.then(() => { this._updateUI(); })
|
|
1782
|
-
.catch(error => {
|
|
2328
|
+
.catch(error => {
|
|
2329
|
+
this._reportError('redo failed', error);
|
|
2330
|
+
throw error;
|
|
2331
|
+
});
|
|
1783
2332
|
}
|
|
1784
2333
|
|
|
1785
2334
|
_rebindMaskEvents(mask) {
|
|
@@ -1801,23 +2350,17 @@ function ensureFabric() {
|
|
|
1801
2350
|
}
|
|
1802
2351
|
if (Object.keys(metadata).length) mask.set(metadata);
|
|
1803
2352
|
|
|
1804
|
-
const normalStyle = {
|
|
1805
|
-
stroke: mask.originalStroke || '#ccc',
|
|
1806
|
-
strokeWidth: mask.originalStrokeWidth,
|
|
1807
|
-
opacity: mask.originalAlpha
|
|
1808
|
-
};
|
|
1809
|
-
const hoverStyle = {
|
|
1810
|
-
stroke: '#ff5500',
|
|
1811
|
-
strokeWidth: 2,
|
|
1812
|
-
opacity: Math.min(mask.originalAlpha + 0.2, 1)
|
|
1813
|
-
};
|
|
1814
|
-
|
|
1815
2353
|
const mouseover = () => {
|
|
1816
|
-
mask.
|
|
2354
|
+
const opacity = Number(mask.originalAlpha);
|
|
2355
|
+
mask.set({
|
|
2356
|
+
stroke: '#ff5500',
|
|
2357
|
+
strokeWidth: 2,
|
|
2358
|
+
opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
|
|
2359
|
+
});
|
|
1817
2360
|
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1818
2361
|
};
|
|
1819
2362
|
const mouseout = () => {
|
|
1820
|
-
mask.set(
|
|
2363
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
1821
2364
|
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1822
2365
|
};
|
|
1823
2366
|
|
|
@@ -1856,6 +2399,7 @@ function ensureFabric() {
|
|
|
1856
2399
|
*/
|
|
1857
2400
|
createMask(config = {}) {
|
|
1858
2401
|
if (!this.canvas) return null;
|
|
2402
|
+
if (!this._canMutateNow('createMask')) return null;
|
|
1859
2403
|
const shapeType = config.shape || 'rect';
|
|
1860
2404
|
// Normalize mask defaults before applying caller-provided overrides.
|
|
1861
2405
|
const maskConfig = {
|
|
@@ -1898,15 +2442,12 @@ function ensureFabric() {
|
|
|
1898
2442
|
|
|
1899
2443
|
if (maskConfig.left === undefined && this._lastMask) {
|
|
1900
2444
|
const previousMask = this._lastMask;
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
}
|
|
1908
|
-
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1909
|
-
top = previousMask.top ?? firstOffset;
|
|
2445
|
+
if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
|
|
2446
|
+
const previousBounds = typeof previousMask.getBoundingRect === 'function'
|
|
2447
|
+
? previousMask.getBoundingRect(true, true)
|
|
2448
|
+
: { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
|
|
2449
|
+
left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
|
|
2450
|
+
top = Math.round(previousBounds.top ?? firstOffset);
|
|
1910
2451
|
} else {
|
|
1911
2452
|
left = resolveValue(maskConfig.left, firstOffset, 'width');
|
|
1912
2453
|
top = resolveValue(maskConfig.top, firstOffset, 'height');
|
|
@@ -2044,6 +2585,8 @@ function ensureFabric() {
|
|
|
2044
2585
|
* The associated label is also removed. UI and mask list are updated.
|
|
2045
2586
|
*/
|
|
2046
2587
|
removeSelectedMask() {
|
|
2588
|
+
if (!this.canvas) return;
|
|
2589
|
+
if (!this._canMutateNow('removeSelectedMask')) return;
|
|
2047
2590
|
const activeObject = this.canvas.getActiveObject();
|
|
2048
2591
|
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
2049
2592
|
if (!selectedMasks.length) return;
|
|
@@ -2072,6 +2615,8 @@ function ensureFabric() {
|
|
|
2072
2615
|
* UI and internal mask placement memory are reset.
|
|
2073
2616
|
*/
|
|
2074
2617
|
removeAllMasks(options = {}) {
|
|
2618
|
+
if (!this.canvas) return;
|
|
2619
|
+
if (!this._canMutateNow('removeAllMasks', options)) return;
|
|
2075
2620
|
const saveHistory = options.saveHistory !== false;
|
|
2076
2621
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2077
2622
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
@@ -2137,6 +2682,10 @@ function ensureFabric() {
|
|
|
2137
2682
|
let textObject = null;
|
|
2138
2683
|
if (this.options.label && typeof this.options.label.create === 'function') {
|
|
2139
2684
|
textObject = this.options.label.create(mask, fabric);
|
|
2685
|
+
if (!textObject || typeof textObject.set !== 'function') {
|
|
2686
|
+
this._reportWarning('label.create() returned an invalid Fabric object; using the default label');
|
|
2687
|
+
textObject = null;
|
|
2688
|
+
}
|
|
2140
2689
|
}
|
|
2141
2690
|
if (!textObject) {
|
|
2142
2691
|
let labelText = mask.maskName;
|
|
@@ -2203,10 +2752,11 @@ function ensureFabric() {
|
|
|
2203
2752
|
if (!this.options.maskLabelOnSelect) return;
|
|
2204
2753
|
if (!mask.__label) return;
|
|
2205
2754
|
|
|
2206
|
-
|
|
2207
|
-
|
|
2755
|
+
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
2756
|
+
const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
|
|
2757
|
+
if (!bounds) return;
|
|
2208
2758
|
|
|
2209
|
-
const tl =
|
|
2759
|
+
const tl = { x: bounds.left, y: bounds.top };
|
|
2210
2760
|
const center = mask.getCenterPoint();
|
|
2211
2761
|
|
|
2212
2762
|
const vx = center.x - tl.x;
|
|
@@ -2289,7 +2839,7 @@ function ensureFabric() {
|
|
|
2289
2839
|
* @private
|
|
2290
2840
|
*/
|
|
2291
2841
|
_updateMaskList() {
|
|
2292
|
-
const maskListElement =
|
|
2842
|
+
const maskListElement = this._getElement('maskList');
|
|
2293
2843
|
if (!maskListElement) return;
|
|
2294
2844
|
maskListElement.innerHTML = '';
|
|
2295
2845
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
@@ -2297,11 +2847,22 @@ function ensureFabric() {
|
|
|
2297
2847
|
const listItemElement = document.createElement('li');
|
|
2298
2848
|
listItemElement.className = 'list-group-item mask-item';
|
|
2299
2849
|
listItemElement.textContent = mask.maskName;
|
|
2300
|
-
listItemElement.
|
|
2850
|
+
listItemElement.dataset.maskId = String(mask.maskId);
|
|
2301
2851
|
maskListElement.appendChild(listItemElement);
|
|
2302
2852
|
});
|
|
2303
2853
|
}
|
|
2304
2854
|
|
|
2855
|
+
_handleMaskListClick(event) {
|
|
2856
|
+
if (!this.canvas) return;
|
|
2857
|
+
const itemElement = event.target && event.target.closest ? event.target.closest('.mask-item') : null;
|
|
2858
|
+
if (!itemElement || !itemElement.dataset) return;
|
|
2859
|
+
const maskId = Number(itemElement.dataset.maskId);
|
|
2860
|
+
const mask = this.canvas.getObjects().find(object => Number(object.maskId) === maskId);
|
|
2861
|
+
if (!mask) return;
|
|
2862
|
+
this.canvas.setActiveObject(mask);
|
|
2863
|
+
this._handleSelectionChanged([mask]);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2305
2866
|
/**
|
|
2306
2867
|
* Updates the visual selection (CSS 'active') state for the mask list in the DOM.
|
|
2307
2868
|
*
|
|
@@ -2309,12 +2870,13 @@ function ensureFabric() {
|
|
|
2309
2870
|
* @private
|
|
2310
2871
|
*/
|
|
2311
2872
|
_updateMaskListSelection(selectedMask) {
|
|
2312
|
-
const maskListElement =
|
|
2873
|
+
const maskListElement = this._getElement('maskList');
|
|
2313
2874
|
if (!maskListElement) return;
|
|
2314
2875
|
const maskItems = maskListElement.querySelectorAll('.mask-item');
|
|
2315
2876
|
maskItems.forEach(item => {
|
|
2316
|
-
const isSelected = !!selectedMask && item.
|
|
2877
|
+
const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
|
|
2317
2878
|
item.classList.toggle('active', isSelected);
|
|
2879
|
+
item.classList.toggle('selected', isSelected);
|
|
2318
2880
|
});
|
|
2319
2881
|
}
|
|
2320
2882
|
|
|
@@ -2330,21 +2892,38 @@ function ensureFabric() {
|
|
|
2330
2892
|
*/
|
|
2331
2893
|
async mergeMasks() {
|
|
2332
2894
|
if (!this.originalImage) return;
|
|
2895
|
+
this._assertIdleForOperation('mergeMasks');
|
|
2333
2896
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2334
2897
|
if (!masks.length) return;
|
|
2898
|
+
const beforeJson = this._serializeCanvasState();
|
|
2899
|
+
const operationToken = this._beginBusyOperation('mergeMasks');
|
|
2335
2900
|
|
|
2336
2901
|
this.canvas.discardActiveObject();
|
|
2337
2902
|
this.canvas.renderAll();
|
|
2338
2903
|
|
|
2339
2904
|
try {
|
|
2340
|
-
const
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2905
|
+
const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
|
|
2906
|
+
exportImageArea: true,
|
|
2907
|
+
multiplier: this.options.exportMultiplier,
|
|
2908
|
+
fileType: 'png'
|
|
2909
|
+
}));
|
|
2910
|
+
this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
|
|
2911
|
+
await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
|
|
2912
|
+
preserveScroll: true,
|
|
2913
|
+
resetMaskCounter: false
|
|
2914
|
+
}));
|
|
2344
2915
|
const afterJson = this._serializeCanvasState();
|
|
2345
2916
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2346
2917
|
} catch (error) {
|
|
2347
2918
|
this._reportError('merge error', error);
|
|
2919
|
+
try {
|
|
2920
|
+
await this.loadFromState(beforeJson);
|
|
2921
|
+
} catch (restoreError) {
|
|
2922
|
+
this._reportError('mergeMasks rollback failed', restoreError);
|
|
2923
|
+
}
|
|
2924
|
+
throw error;
|
|
2925
|
+
} finally {
|
|
2926
|
+
this._endBusyOperation(operationToken);
|
|
2348
2927
|
}
|
|
2349
2928
|
}
|
|
2350
2929
|
|
|
@@ -2368,6 +2947,7 @@ function ensureFabric() {
|
|
|
2368
2947
|
*/
|
|
2369
2948
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
2370
2949
|
if (!this.originalImage) return;
|
|
2950
|
+
if (!this._canMutateNow('downloadImage')) return;
|
|
2371
2951
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
2372
2952
|
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
|
|
2373
2953
|
.then(imageBase64 => {
|
|
@@ -2399,6 +2979,7 @@ function ensureFabric() {
|
|
|
2399
2979
|
*/
|
|
2400
2980
|
async exportImageBase64(options = {}) {
|
|
2401
2981
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
2982
|
+
this._assertIdleForOperation('exportImageBase64', options);
|
|
2402
2983
|
const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
2403
2984
|
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
2404
2985
|
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
@@ -2415,12 +2996,13 @@ function ensureFabric() {
|
|
|
2415
2996
|
|
|
2416
2997
|
this.originalImage.setCoords();
|
|
2417
2998
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2418
|
-
const exportRegion = this._getClampedCanvasRegion(imageBounds
|
|
2999
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
2419
3000
|
return await this._exportCanvasRegionToDataURL({
|
|
2420
3001
|
...exportRegion,
|
|
2421
3002
|
multiplier,
|
|
2422
3003
|
quality,
|
|
2423
|
-
format
|
|
3004
|
+
format,
|
|
3005
|
+
sealPartialEdges: this._getPartialExportEdges(imageBounds)
|
|
2424
3006
|
});
|
|
2425
3007
|
} finally {
|
|
2426
3008
|
maskVisibilityBackups.forEach(backup => {
|
|
@@ -2459,14 +3041,15 @@ function ensureFabric() {
|
|
|
2459
3041
|
// Compute an integer canvas region for the base image.
|
|
2460
3042
|
this.originalImage.setCoords();
|
|
2461
3043
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2462
|
-
const exportRegion = this._getClampedCanvasRegion(imageBounds
|
|
3044
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
2463
3045
|
|
|
2464
3046
|
// Crop precisely in offscreen canvas
|
|
2465
3047
|
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
2466
3048
|
...exportRegion,
|
|
2467
3049
|
multiplier,
|
|
2468
3050
|
quality,
|
|
2469
|
-
format
|
|
3051
|
+
format,
|
|
3052
|
+
sealPartialEdges: this._getPartialExportEdges(imageBounds)
|
|
2470
3053
|
});
|
|
2471
3054
|
} finally {
|
|
2472
3055
|
maskStyleBackups.forEach(backup => {
|
|
@@ -2520,6 +3103,7 @@ function ensureFabric() {
|
|
|
2520
3103
|
*/
|
|
2521
3104
|
async exportImageFile(options = {}) {
|
|
2522
3105
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
3106
|
+
this._assertIdleForOperation('exportImageFile');
|
|
2523
3107
|
const {
|
|
2524
3108
|
mergeMask = true,
|
|
2525
3109
|
fileType = 'jpeg',
|
|
@@ -2529,6 +3113,7 @@ function ensureFabric() {
|
|
|
2529
3113
|
} = options;
|
|
2530
3114
|
|
|
2531
3115
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
3116
|
+
const normalizedQuality = this._normalizeQuality(quality);
|
|
2532
3117
|
|
|
2533
3118
|
// Generate the data URL in the requested export mode.
|
|
2534
3119
|
let imageBase64;
|
|
@@ -2536,14 +3121,14 @@ function ensureFabric() {
|
|
|
2536
3121
|
imageBase64 = await this.exportImageBase64({
|
|
2537
3122
|
exportImageArea: true,
|
|
2538
3123
|
multiplier,
|
|
2539
|
-
quality,
|
|
3124
|
+
quality: normalizedQuality,
|
|
2540
3125
|
fileType: safeFileType
|
|
2541
3126
|
});
|
|
2542
3127
|
} else {
|
|
2543
3128
|
imageBase64 = await this.exportImageBase64({
|
|
2544
3129
|
exportImageArea: false,
|
|
2545
3130
|
multiplier,
|
|
2546
|
-
quality,
|
|
3131
|
+
quality: normalizedQuality,
|
|
2547
3132
|
fileType: safeFileType
|
|
2548
3133
|
});
|
|
2549
3134
|
}
|
|
@@ -2561,8 +3146,9 @@ function ensureFabric() {
|
|
|
2561
3146
|
offscreenCanvas.width = imageElement.width;
|
|
2562
3147
|
offscreenCanvas.height = imageElement.height;
|
|
2563
3148
|
const context = offscreenCanvas.getContext('2d');
|
|
3149
|
+
if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
|
|
2564
3150
|
context.drawImage(imageElement, 0, 0);
|
|
2565
|
-
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`,
|
|
3151
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
|
|
2566
3152
|
resolve(convertedDataUrl);
|
|
2567
3153
|
} catch (error) { reject(error); }
|
|
2568
3154
|
};
|
|
@@ -2633,13 +3219,15 @@ function ensureFabric() {
|
|
|
2633
3219
|
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2634
3220
|
this._cropHandlers.forEach(targetHandlers => {
|
|
2635
3221
|
targetHandlers.handlers.forEach(handlerRecord => {
|
|
2636
|
-
targetHandlers.target.off
|
|
3222
|
+
if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
|
|
3223
|
+
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
3224
|
+
}
|
|
2637
3225
|
});
|
|
2638
3226
|
});
|
|
2639
3227
|
}
|
|
2640
3228
|
} catch (error) { void error; }
|
|
2641
3229
|
|
|
2642
|
-
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
3230
|
+
try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
2643
3231
|
this._cropRect = null;
|
|
2644
3232
|
this._cropHandlers = [];
|
|
2645
3233
|
}
|
|
@@ -2655,7 +3243,9 @@ function ensureFabric() {
|
|
|
2655
3243
|
*/
|
|
2656
3244
|
enterCropMode() {
|
|
2657
3245
|
if (!this.canvas || !this.originalImage || this._cropMode) return;
|
|
3246
|
+
if (!this._canMutateNow('enterCropMode')) return;
|
|
2658
3247
|
if (!this.isImageLoaded()) return;
|
|
3248
|
+
this._removeCropRect();
|
|
2659
3249
|
this._cropMode = true;
|
|
2660
3250
|
|
|
2661
3251
|
// Disable group selection so only the crop rectangle can be manipulated.
|
|
@@ -2789,6 +3379,7 @@ function ensureFabric() {
|
|
|
2789
3379
|
*/
|
|
2790
3380
|
async applyCrop() {
|
|
2791
3381
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
3382
|
+
this._assertIdleForOperation('applyCrop');
|
|
2792
3383
|
|
|
2793
3384
|
// Fabric does not update control coordinates automatically after programmatic transforms.
|
|
2794
3385
|
this._cropRect.setCoords();
|
|
@@ -2824,12 +3415,8 @@ function ensureFabric() {
|
|
|
2824
3415
|
this._removeLabelForMask(mask);
|
|
2825
3416
|
this.canvas.remove(mask);
|
|
2826
3417
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2827
|
-
mask.
|
|
2828
|
-
|
|
2829
|
-
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2830
|
-
visible: true
|
|
2831
|
-
});
|
|
2832
|
-
mask.setCoords();
|
|
3418
|
+
this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
|
|
3419
|
+
mask.set({ visible: true });
|
|
2833
3420
|
preservedMasks.push(mask);
|
|
2834
3421
|
}
|
|
2835
3422
|
} catch (error) {
|
|
@@ -2867,7 +3454,7 @@ function ensureFabric() {
|
|
|
2867
3454
|
|
|
2868
3455
|
// Load the cropped image as the new base image.
|
|
2869
3456
|
try {
|
|
2870
|
-
await this.loadImage(croppedBase64);
|
|
3457
|
+
await this.loadImage(croppedBase64, { resetMaskCounter: false });
|
|
2871
3458
|
if (preservedMasks.length) {
|
|
2872
3459
|
preservedMasks.forEach(mask => {
|
|
2873
3460
|
this._rebindMaskEvents(mask);
|
|
@@ -2887,7 +3474,7 @@ function ensureFabric() {
|
|
|
2887
3474
|
// Create an after snapshot and push one history command for the crop operation.
|
|
2888
3475
|
let afterJson;
|
|
2889
3476
|
try {
|
|
2890
|
-
afterJson = this._serializeCanvasState();
|
|
3477
|
+
afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
|
|
2891
3478
|
} catch (error) {
|
|
2892
3479
|
this._reportWarning('applyCrop: failed to serialize after state', error);
|
|
2893
3480
|
afterJson = null;
|
|
@@ -2913,7 +3500,7 @@ function ensureFabric() {
|
|
|
2913
3500
|
* @private
|
|
2914
3501
|
*/
|
|
2915
3502
|
_updateInputs() {
|
|
2916
|
-
const scaleInputElement =
|
|
3503
|
+
const scaleInputElement = this._getElement('scaleRate');
|
|
2917
3504
|
if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
2918
3505
|
}
|
|
2919
3506
|
|
|
@@ -2923,6 +3510,7 @@ function ensureFabric() {
|
|
|
2923
3510
|
* @private
|
|
2924
3511
|
*/
|
|
2925
3512
|
_updateUI() {
|
|
3513
|
+
if (!this.canvas) return;
|
|
2926
3514
|
const hasImage = !!this.originalImage;
|
|
2927
3515
|
const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
|
|
2928
3516
|
const hasMasks = masks.length > 0;
|
|
@@ -2932,11 +3520,12 @@ function ensureFabric() {
|
|
|
2932
3520
|
const canUndo = this.historyManager?.canUndo();
|
|
2933
3521
|
const canRedo = this.historyManager?.canRedo();
|
|
2934
3522
|
const isInCropMode = !!this._cropMode;
|
|
3523
|
+
const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
|
|
2935
3524
|
|
|
2936
3525
|
if (isInCropMode) {
|
|
2937
3526
|
// Disable all controls except the crop action buttons while crop mode is active.
|
|
2938
3527
|
for (const key of Object.keys(this.elements || {})) {
|
|
2939
|
-
const element =
|
|
3528
|
+
const element = this._getElement(key);
|
|
2940
3529
|
if (!element) continue;
|
|
2941
3530
|
if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
|
|
2942
3531
|
this._setDisabled(key, false);
|
|
@@ -2947,23 +3536,23 @@ function ensureFabric() {
|
|
|
2947
3536
|
return;
|
|
2948
3537
|
}
|
|
2949
3538
|
|
|
2950
|
-
this._setDisabled('zoomInBtn', !hasImage ||
|
|
2951
|
-
this._setDisabled('zoomOutBtn', !hasImage ||
|
|
2952
|
-
this._setDisabled('rotateLeftBtn', !hasImage ||
|
|
2953
|
-
this._setDisabled('rotateRightBtn', !hasImage ||
|
|
2954
|
-
this._setDisabled('addMaskBtn', !hasImage ||
|
|
2955
|
-
this._setDisabled('removeMaskBtn', !hasSelectedMask ||
|
|
2956
|
-
this._setDisabled('removeAllMasksBtn', !hasMasks ||
|
|
2957
|
-
this._setDisabled('mergeBtn', !hasImage || !hasMasks ||
|
|
2958
|
-
this._setDisabled('downloadBtn', !hasImage ||
|
|
2959
|
-
this._setDisabled('resetBtn', !hasImage || isDefaultTransform ||
|
|
2960
|
-
this._setDisabled('undoBtn', !hasImage ||
|
|
2961
|
-
this._setDisabled('redoBtn', !hasImage ||
|
|
2962
|
-
this._setDisabled('cropBtn', !hasImage ||
|
|
3539
|
+
this._setDisabled('zoomInBtn', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
|
|
3540
|
+
this._setDisabled('zoomOutBtn', !hasImage || isBusy || this.currentScale <= this.options.minScale);
|
|
3541
|
+
this._setDisabled('rotateLeftBtn', !hasImage || isBusy);
|
|
3542
|
+
this._setDisabled('rotateRightBtn', !hasImage || isBusy);
|
|
3543
|
+
this._setDisabled('addMaskBtn', !hasImage || isBusy);
|
|
3544
|
+
this._setDisabled('removeMaskBtn', !hasSelectedMask || isBusy);
|
|
3545
|
+
this._setDisabled('removeAllMasksBtn', !hasMasks || isBusy);
|
|
3546
|
+
this._setDisabled('mergeBtn', !hasImage || !hasMasks || isBusy);
|
|
3547
|
+
this._setDisabled('downloadBtn', !hasImage || isBusy);
|
|
3548
|
+
this._setDisabled('resetBtn', !hasImage || isDefaultTransform || isBusy);
|
|
3549
|
+
this._setDisabled('undoBtn', !hasImage || isBusy || !canUndo);
|
|
3550
|
+
this._setDisabled('redoBtn', !hasImage || isBusy || !canRedo);
|
|
3551
|
+
this._setDisabled('cropBtn', !hasImage || isBusy);
|
|
2963
3552
|
this._setDisabled('applyCropBtn', true);
|
|
2964
3553
|
this._setDisabled('cancelCropBtn', true);
|
|
2965
|
-
this._setDisabled('imageInput',
|
|
2966
|
-
this._setDisabled('uploadArea',
|
|
3554
|
+
this._setDisabled('imageInput', isBusy);
|
|
3555
|
+
this._setDisabled('uploadArea', isBusy);
|
|
2967
3556
|
}
|
|
2968
3557
|
|
|
2969
3558
|
/**
|
|
@@ -2974,19 +3563,23 @@ function ensureFabric() {
|
|
|
2974
3563
|
* @private
|
|
2975
3564
|
*/
|
|
2976
3565
|
_setDisabled(key, disabled) {
|
|
2977
|
-
const element =
|
|
3566
|
+
const element = this._getElement(key);
|
|
2978
3567
|
if (!element) return;
|
|
2979
3568
|
if ('disabled' in element) {
|
|
2980
3569
|
element.disabled = !!disabled;
|
|
2981
3570
|
return;
|
|
2982
3571
|
}
|
|
3572
|
+
if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = new Map();
|
|
3573
|
+
if (!this._elementOriginalPointerEvents.has(key)) {
|
|
3574
|
+
this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || '');
|
|
3575
|
+
}
|
|
2983
3576
|
|
|
2984
3577
|
if (disabled) {
|
|
2985
3578
|
element.setAttribute('aria-disabled', 'true');
|
|
2986
3579
|
element.style.pointerEvents = 'none';
|
|
2987
3580
|
} else {
|
|
2988
3581
|
element.removeAttribute('aria-disabled');
|
|
2989
|
-
element.style.pointerEvents = '';
|
|
3582
|
+
element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? '';
|
|
2990
3583
|
}
|
|
2991
3584
|
}
|
|
2992
3585
|
|
|
@@ -3012,9 +3605,23 @@ function ensureFabric() {
|
|
|
3012
3605
|
* @private
|
|
3013
3606
|
*/
|
|
3014
3607
|
_setPlaceholderVisible(show) {
|
|
3015
|
-
if (
|
|
3016
|
-
this.
|
|
3017
|
-
|
|
3608
|
+
if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
|
|
3609
|
+
const canvasVisibilityElement = this._getCanvasVisibilityElement();
|
|
3610
|
+
if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
|
|
3611
|
+
this._setElementVisible(canvasVisibilityElement, !show);
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
_getCanvasVisibilityElement() {
|
|
3616
|
+
const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
|
|
3617
|
+
if (
|
|
3618
|
+
this.containerElement &&
|
|
3619
|
+
this.placeholderElement &&
|
|
3620
|
+
(this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))
|
|
3621
|
+
) {
|
|
3622
|
+
return wrapperElement || this.canvasElement;
|
|
3623
|
+
}
|
|
3624
|
+
return this.containerElement || wrapperElement || this.canvasElement;
|
|
3018
3625
|
}
|
|
3019
3626
|
|
|
3020
3627
|
/**
|
|
@@ -3027,9 +3634,37 @@ function ensureFabric() {
|
|
|
3027
3634
|
*/
|
|
3028
3635
|
_setElementVisible(element, isVisible) {
|
|
3029
3636
|
if (!element) return;
|
|
3637
|
+
this._rememberElementVisibility(element);
|
|
3030
3638
|
element.hidden = !isVisible;
|
|
3031
3639
|
element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
|
|
3032
|
-
if (
|
|
3640
|
+
if (element.classList) {
|
|
3641
|
+
element.classList.toggle('d-none', !isVisible);
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
_rememberElementVisibility(element) {
|
|
3646
|
+
if (!element || this._visibilityStateByElement.has(element)) return;
|
|
3647
|
+
this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
_captureElementVisibility(element) {
|
|
3651
|
+
if (!element) return null;
|
|
3652
|
+
return {
|
|
3653
|
+
hidden: element.hidden,
|
|
3654
|
+
ariaHidden: element.getAttribute('aria-hidden'),
|
|
3655
|
+
className: element.className
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
_restoreElementVisibility(element, state) {
|
|
3660
|
+
if (!element || !state) return;
|
|
3661
|
+
element.hidden = !!state.hidden;
|
|
3662
|
+
if (state.ariaHidden === null) {
|
|
3663
|
+
element.removeAttribute('aria-hidden');
|
|
3664
|
+
} else {
|
|
3665
|
+
element.setAttribute('aria-hidden', state.ariaHidden);
|
|
3666
|
+
}
|
|
3667
|
+
element.className = state.className || '';
|
|
3033
3668
|
}
|
|
3034
3669
|
|
|
3035
3670
|
/**
|
|
@@ -3038,11 +3673,19 @@ function ensureFabric() {
|
|
|
3038
3673
|
* @public
|
|
3039
3674
|
*/
|
|
3040
3675
|
dispose() {
|
|
3676
|
+
this._disposed = true;
|
|
3677
|
+
this._rejectActiveAnimations(new Error('Editor disposed during animation'));
|
|
3678
|
+
if (this.animationQueue) {
|
|
3679
|
+
this.animationQueue.cancelAll(new Error('Editor disposed'));
|
|
3680
|
+
}
|
|
3681
|
+
this._isLoading = false;
|
|
3682
|
+
this._activeOperationName = null;
|
|
3683
|
+
this._activeOperationToken = null;
|
|
3684
|
+
|
|
3041
3685
|
// Remove bound DOM event listeners
|
|
3042
3686
|
try {
|
|
3043
|
-
for (const key
|
|
3044
|
-
const
|
|
3045
|
-
const element = document.getElementById(this.elements[key]);
|
|
3687
|
+
for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
|
|
3688
|
+
const element = this._getElement(key);
|
|
3046
3689
|
if (!element) continue;
|
|
3047
3690
|
handlers.forEach(handlerRecord => {
|
|
3048
3691
|
try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
|
|
@@ -3055,8 +3698,25 @@ function ensureFabric() {
|
|
|
3055
3698
|
this._cropRect = null;
|
|
3056
3699
|
}
|
|
3057
3700
|
|
|
3058
|
-
if (this.containerElement && this._containerOriginalOverflow
|
|
3059
|
-
try { this.
|
|
3701
|
+
if (this.containerElement && this._containerOriginalOverflow) {
|
|
3702
|
+
try { this._restoreContainerOverflowState(); } catch (error) { void error; }
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
if (this._visibilityStateByElement) {
|
|
3706
|
+
try {
|
|
3707
|
+
[this.placeholderElement, this._getCanvasVisibilityElement()].forEach(element => {
|
|
3708
|
+
const state = element ? this._visibilityStateByElement.get(element) : null;
|
|
3709
|
+
if (state) this._restoreElementVisibility(element, state);
|
|
3710
|
+
});
|
|
3711
|
+
} catch (error) { void error; }
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
if (this.canvasElement && this._canvasElementOriginalStyle) {
|
|
3715
|
+
try {
|
|
3716
|
+
this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
|
|
3717
|
+
this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
|
|
3718
|
+
this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
|
|
3719
|
+
} catch (error) { void error; }
|
|
3060
3720
|
}
|
|
3061
3721
|
|
|
3062
3722
|
if (this.canvas) {
|
|
@@ -3066,6 +3726,22 @@ function ensureFabric() {
|
|
|
3066
3726
|
this.isImageLoadedToCanvas = false;
|
|
3067
3727
|
}
|
|
3068
3728
|
this._handlersByElementKey = {};
|
|
3729
|
+
this._elementCache = {};
|
|
3730
|
+
this._elementOriginalPointerEvents = new Map();
|
|
3731
|
+
this._clearMaskPlacementMemory();
|
|
3732
|
+
this.originalImage = null;
|
|
3733
|
+
this.baseImageScale = 1;
|
|
3734
|
+
this.currentScale = 1;
|
|
3735
|
+
this.currentRotation = 0;
|
|
3736
|
+
this.isAnimating = false;
|
|
3737
|
+
this._isLoading = false;
|
|
3738
|
+
this._cropMode = false;
|
|
3739
|
+
this._cropRect = null;
|
|
3740
|
+
this._cropHandlers = [];
|
|
3741
|
+
this._cropPrevEvented = null;
|
|
3742
|
+
this._prevSelectionSetting = undefined;
|
|
3743
|
+
this._lastContainerViewportSize = null;
|
|
3744
|
+
this._initialized = false;
|
|
3069
3745
|
}
|
|
3070
3746
|
}
|
|
3071
3747
|
|
|
@@ -3118,6 +3794,8 @@ function ensureFabric() {
|
|
|
3118
3794
|
* @type {boolean}
|
|
3119
3795
|
*/
|
|
3120
3796
|
this.isRunning = false;
|
|
3797
|
+
this.currentTask = null;
|
|
3798
|
+
this._generation = 0;
|
|
3121
3799
|
}
|
|
3122
3800
|
|
|
3123
3801
|
/**
|
|
@@ -3128,13 +3806,33 @@ function ensureFabric() {
|
|
|
3128
3806
|
*/
|
|
3129
3807
|
async add(animationFn) {
|
|
3130
3808
|
return new Promise((resolve, reject) => {
|
|
3131
|
-
this.animationTasks.push({ animationFn, resolve, reject });
|
|
3809
|
+
this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
|
|
3132
3810
|
if (!this.isRunning) {
|
|
3133
3811
|
this._drainQueue();
|
|
3134
3812
|
}
|
|
3135
3813
|
});
|
|
3136
3814
|
}
|
|
3137
3815
|
|
|
3816
|
+
isBusy() {
|
|
3817
|
+
return this.isRunning || this.animationTasks.length > 0;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
cancelAll(reason = new Error('Animation queue cancelled')) {
|
|
3821
|
+
this._generation += 1;
|
|
3822
|
+
const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
|
|
3823
|
+
const tasks = [
|
|
3824
|
+
...(this.currentTask ? [this.currentTask] : []),
|
|
3825
|
+
...this.animationTasks.splice(0)
|
|
3826
|
+
];
|
|
3827
|
+
tasks.forEach(task => {
|
|
3828
|
+
if (!task || task.isSettled) return;
|
|
3829
|
+
task.isSettled = true;
|
|
3830
|
+
task.reject(cancellationError);
|
|
3831
|
+
});
|
|
3832
|
+
this.isRunning = false;
|
|
3833
|
+
this.currentTask = null;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3138
3836
|
/**
|
|
3139
3837
|
* Runs queued animation tasks sequentially until the queue is empty.
|
|
3140
3838
|
*
|
|
@@ -3142,22 +3840,36 @@ function ensureFabric() {
|
|
|
3142
3840
|
* @returns {Promise<void>}
|
|
3143
3841
|
*/
|
|
3144
3842
|
async _drainQueue() {
|
|
3145
|
-
if (this.
|
|
3146
|
-
|
|
3147
|
-
return;
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3843
|
+
if (this.isRunning) return;
|
|
3844
|
+
const generation = this._generation;
|
|
3150
3845
|
this.isRunning = true;
|
|
3151
|
-
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
3152
3846
|
|
|
3153
3847
|
try {
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
reject(error);
|
|
3158
|
-
}
|
|
3848
|
+
while (this.animationTasks.length > 0 && generation === this._generation) {
|
|
3849
|
+
const task = this.animationTasks.shift();
|
|
3850
|
+
this.currentTask = task;
|
|
3159
3851
|
|
|
3160
|
-
|
|
3852
|
+
try {
|
|
3853
|
+
const result = await task.animationFn();
|
|
3854
|
+
if (generation === this._generation && !task.isSettled) {
|
|
3855
|
+
task.isSettled = true;
|
|
3856
|
+
task.resolve(result);
|
|
3857
|
+
}
|
|
3858
|
+
} catch (error) {
|
|
3859
|
+
if (generation === this._generation && !task.isSettled) {
|
|
3860
|
+
task.isSettled = true;
|
|
3861
|
+
task.reject(error);
|
|
3862
|
+
}
|
|
3863
|
+
} finally {
|
|
3864
|
+
if (generation === this._generation && this.currentTask === task) this.currentTask = null;
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
} finally {
|
|
3868
|
+
if (generation === this._generation) {
|
|
3869
|
+
this.isRunning = false;
|
|
3870
|
+
this.currentTask = null;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3161
3873
|
}
|
|
3162
3874
|
}
|
|
3163
3875
|
|
|
@@ -3213,16 +3925,8 @@ function ensureFabric() {
|
|
|
3213
3925
|
* @private
|
|
3214
3926
|
*/
|
|
3215
3927
|
enqueue(task) {
|
|
3216
|
-
const nextTask = this.pending.then(
|
|
3217
|
-
|
|
3218
|
-
const resetPending = () => {
|
|
3219
|
-
if (this.pending === pendingAfterTask) {
|
|
3220
|
-
this.pending = Promise.resolve();
|
|
3221
|
-
}
|
|
3222
|
-
};
|
|
3223
|
-
|
|
3224
|
-
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
3225
|
-
this.pending = pendingAfterTask;
|
|
3928
|
+
const nextTask = this.pending.then(() => Promise.resolve().then(task));
|
|
3929
|
+
this.pending = nextTask.catch(() => undefined);
|
|
3226
3930
|
return nextTask;
|
|
3227
3931
|
}
|
|
3228
3932
|
|
|
@@ -3234,8 +3938,14 @@ function ensureFabric() {
|
|
|
3234
3938
|
* @returns {void}
|
|
3235
3939
|
*/
|
|
3236
3940
|
execute(command) {
|
|
3237
|
-
command.execute();
|
|
3941
|
+
const result = command.execute();
|
|
3942
|
+
if (result && typeof result.then === 'function') {
|
|
3943
|
+
return Promise.resolve(result).then(() => {
|
|
3944
|
+
this.push(command);
|
|
3945
|
+
});
|
|
3946
|
+
}
|
|
3238
3947
|
this.push(command);
|
|
3948
|
+
return result;
|
|
3239
3949
|
}
|
|
3240
3950
|
|
|
3241
3951
|
/**
|
|
@@ -3255,9 +3965,8 @@ function ensureFabric() {
|
|
|
3255
3965
|
|
|
3256
3966
|
if (this.history.length > this.maxSize) {
|
|
3257
3967
|
this.history.shift();
|
|
3258
|
-
} else {
|
|
3259
|
-
this.currentIndex++;
|
|
3260
3968
|
}
|
|
3969
|
+
this.currentIndex = this.history.length - 1;
|
|
3261
3970
|
}
|
|
3262
3971
|
|
|
3263
3972
|
/**
|