@bensitu/image-editor 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image-editor.esm.js +946 -542
- 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 +946 -542
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +946 -542
- 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 +2 -0
- package/package.json +9 -4
- package/src/image-editor.js +899 -358
package/src/image-editor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file image-editor.js
|
|
3
3
|
* @module image-editor
|
|
4
|
-
* @version 1.
|
|
4
|
+
* @version 1.4.0
|
|
5
5
|
* @author Ben Situ
|
|
6
6
|
* @license MIT
|
|
7
7
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
@@ -196,6 +196,8 @@ function ensureFabric() {
|
|
|
196
196
|
downsampleMaxWidth: 4000,
|
|
197
197
|
downsampleMaxHeight: 3000,
|
|
198
198
|
downsampleQuality: 0.92,
|
|
199
|
+
preserveSourceFormat: true,
|
|
200
|
+
downsampleMimeType: null,
|
|
199
201
|
imageLoadTimeoutMs: 30000,
|
|
200
202
|
|
|
201
203
|
exportMultiplier: 1,
|
|
@@ -255,6 +257,7 @@ function ensureFabric() {
|
|
|
255
257
|
this.maxHistorySize = 50;
|
|
256
258
|
|
|
257
259
|
this._handlersByElementKey = {};
|
|
260
|
+
this._elementCache = {};
|
|
258
261
|
|
|
259
262
|
this._lastMask = null;
|
|
260
263
|
this._lastMaskInitialLeft = null;
|
|
@@ -267,8 +270,14 @@ function ensureFabric() {
|
|
|
267
270
|
this._cropHandlers = [];
|
|
268
271
|
this._cropPrevEvented = null;
|
|
269
272
|
this._prevSelectionSetting = undefined;
|
|
270
|
-
this._containerOriginalOverflow =
|
|
273
|
+
this._containerOriginalOverflow = null;
|
|
274
|
+
this._lastContainerViewportSize = null;
|
|
275
|
+
this._canvasElementOriginalStyle = null;
|
|
276
|
+
this._visibilityStateByElement = new WeakMap();
|
|
271
277
|
this._scrollbarSizeCache = null;
|
|
278
|
+
this._activeAnimationRejectors = new Set();
|
|
279
|
+
this._disposed = false;
|
|
280
|
+
this._initialized = false;
|
|
272
281
|
|
|
273
282
|
this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
|
|
274
283
|
|
|
@@ -341,6 +350,16 @@ function ensureFabric() {
|
|
|
341
350
|
*/
|
|
342
351
|
init(idMap = {}) {
|
|
343
352
|
if (!this._fabricLoaded) return;
|
|
353
|
+
if (this._initialized || this.canvas) this.dispose();
|
|
354
|
+
this._disposed = false;
|
|
355
|
+
this._initialized = true;
|
|
356
|
+
this.animationQueue = new AnimationQueue();
|
|
357
|
+
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
358
|
+
this._visibilityStateByElement = new WeakMap();
|
|
359
|
+
this._activeAnimationRejectors = new Set();
|
|
360
|
+
this._containerOriginalOverflow = null;
|
|
361
|
+
this._lastContainerViewportSize = null;
|
|
362
|
+
this._canvasElementOriginalStyle = null;
|
|
344
363
|
|
|
345
364
|
const defaults = {
|
|
346
365
|
canvas: 'fabricCanvas',
|
|
@@ -369,6 +388,7 @@ function ensureFabric() {
|
|
|
369
388
|
};
|
|
370
389
|
|
|
371
390
|
this.elements = { ...defaults, ...idMap };
|
|
391
|
+
this._elementCache = {};
|
|
372
392
|
|
|
373
393
|
this._initCanvas();
|
|
374
394
|
this._bindEvents();
|
|
@@ -413,19 +433,25 @@ function ensureFabric() {
|
|
|
413
433
|
* @private
|
|
414
434
|
*/
|
|
415
435
|
_initCanvas() {
|
|
416
|
-
const canvasElement =
|
|
436
|
+
const canvasElement = this._getElement('canvas');
|
|
417
437
|
if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
|
|
418
438
|
this.canvasElement = canvasElement;
|
|
439
|
+
this._canvasElementOriginalStyle = {
|
|
440
|
+
display: canvasElement.style.display || '',
|
|
441
|
+
width: canvasElement.style.width || '',
|
|
442
|
+
height: canvasElement.style.height || '',
|
|
443
|
+
maxWidth: canvasElement.style.maxWidth || ''
|
|
444
|
+
};
|
|
419
445
|
|
|
420
446
|
// Decide which element acts as the viewport for size fallback and scrolling.
|
|
421
447
|
if (this.elements.canvasContainer) {
|
|
422
|
-
const containerElement =
|
|
448
|
+
const containerElement = this._getElement('canvasContainer');
|
|
423
449
|
this.containerElement = containerElement || canvasElement.parentElement;
|
|
424
450
|
} else {
|
|
425
451
|
this.containerElement = canvasElement.parentElement;
|
|
426
452
|
}
|
|
427
453
|
|
|
428
|
-
this.placeholderElement =
|
|
454
|
+
this.placeholderElement = this._getElement('imgPlaceholder') || null;
|
|
429
455
|
|
|
430
456
|
// Prefer a measured container size when it is available.
|
|
431
457
|
let initialWidth = this.options.canvasWidth;
|
|
@@ -436,6 +462,11 @@ function ensureFabric() {
|
|
|
436
462
|
if (containerWidth > 0 && containerHeight > 0) {
|
|
437
463
|
initialWidth = containerWidth;
|
|
438
464
|
initialHeight = containerHeight;
|
|
465
|
+
|
|
466
|
+
this._lastContainerViewportSize = {
|
|
467
|
+
width: containerWidth,
|
|
468
|
+
height: containerHeight
|
|
469
|
+
};
|
|
439
470
|
}
|
|
440
471
|
}
|
|
441
472
|
|
|
@@ -460,6 +491,24 @@ function ensureFabric() {
|
|
|
460
491
|
this.canvasElement.style.display = 'block';
|
|
461
492
|
}
|
|
462
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Returns a configured DOM element and caches lookups for hot UI paths.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} key - Key in the configured element map.
|
|
498
|
+
* @returns {HTMLElement|null} The configured element, or null when missing.
|
|
499
|
+
* @private
|
|
500
|
+
*/
|
|
501
|
+
_getElement(key) {
|
|
502
|
+
const id = this.elements && this.elements[key];
|
|
503
|
+
if (!id) return null;
|
|
504
|
+
if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
|
|
505
|
+
return this._elementCache[key];
|
|
506
|
+
}
|
|
507
|
+
const element = document.getElementById(id);
|
|
508
|
+
if (this._elementCache) this._elementCache[key] = element || null;
|
|
509
|
+
return element || null;
|
|
510
|
+
}
|
|
511
|
+
|
|
463
512
|
/**
|
|
464
513
|
* Records a history entry after Fabric finishes modifying one or more masks.
|
|
465
514
|
*
|
|
@@ -504,9 +553,7 @@ function ensureFabric() {
|
|
|
504
553
|
*/
|
|
505
554
|
_syncContainerOverflow(options = {}) {
|
|
506
555
|
if (!this.containerElement || !this.containerElement.style) return;
|
|
507
|
-
|
|
508
|
-
this._containerOriginalOverflow = this.containerElement.style.overflow || '';
|
|
509
|
-
}
|
|
556
|
+
this._captureContainerOverflowState();
|
|
510
557
|
|
|
511
558
|
const shouldPreserveScroll = options.preserveScroll === true;
|
|
512
559
|
if (this.options.coverImageToCanvas) {
|
|
@@ -522,10 +569,26 @@ function ensureFabric() {
|
|
|
522
569
|
this.containerElement.scrollTop = 0;
|
|
523
570
|
}
|
|
524
571
|
} else {
|
|
525
|
-
this.
|
|
572
|
+
this._restoreContainerOverflowState();
|
|
526
573
|
}
|
|
527
574
|
}
|
|
528
575
|
|
|
576
|
+
_captureContainerOverflowState() {
|
|
577
|
+
if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
|
|
578
|
+
this._containerOriginalOverflow = {
|
|
579
|
+
overflow: this.containerElement.style.overflow || '',
|
|
580
|
+
overflowX: this.containerElement.style.overflowX || '',
|
|
581
|
+
overflowY: this.containerElement.style.overflowY || ''
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
_restoreContainerOverflowState() {
|
|
586
|
+
if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
|
|
587
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
|
|
588
|
+
this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
|
|
589
|
+
this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
|
|
590
|
+
}
|
|
591
|
+
|
|
529
592
|
/**
|
|
530
593
|
* DOM / UI bindings
|
|
531
594
|
* @private
|
|
@@ -533,54 +596,61 @@ function ensureFabric() {
|
|
|
533
596
|
_bindEvents() {
|
|
534
597
|
// Click anywhere on the upload area opens the native file dialog
|
|
535
598
|
this._bindIfExists('uploadArea', 'click', () => {
|
|
536
|
-
const uploadAreaElement =
|
|
599
|
+
const uploadAreaElement = this._getElement('uploadArea');
|
|
537
600
|
if (this._isElementDisabled(uploadAreaElement)) return;
|
|
538
|
-
|
|
601
|
+
this._getElement('imageInput')?.click();
|
|
539
602
|
});
|
|
540
603
|
// File-input change
|
|
541
604
|
this._bindIfExists('imageInput', 'change', (event) => {
|
|
542
605
|
const file = event.target.files && event.target.files[0];
|
|
543
|
-
if (file)
|
|
606
|
+
if (file) {
|
|
607
|
+
this._loadImageFile(file)
|
|
608
|
+
.catch(error => this._reportError('Image file could not be loaded', error))
|
|
609
|
+
.finally(() => {
|
|
610
|
+
event.target.value = '';
|
|
611
|
+
});
|
|
612
|
+
}
|
|
544
613
|
});
|
|
545
614
|
// 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(); });
|
|
615
|
+
this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
616
|
+
this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
617
|
+
this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
|
|
549
618
|
// Mask management
|
|
550
619
|
this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
|
|
551
620
|
this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
|
|
552
621
|
this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
|
|
553
622
|
// Merge + download
|
|
554
|
-
this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
|
|
623
|
+
this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
|
|
555
624
|
this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
|
|
556
625
|
// Undo + Redo
|
|
557
|
-
this._bindIfExists('undoBtn', 'click', () => this.undo());
|
|
558
|
-
this._bindIfExists('redoBtn', 'click', () => this.redo());
|
|
626
|
+
this._bindIfExists('undoBtn', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
|
|
627
|
+
this._bindIfExists('redoBtn', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
|
|
559
628
|
|
|
560
629
|
// Rotation buttons (step can be overridden by two input fields)
|
|
561
630
|
this._bindIfExists('rotateLeftBtn', 'click', () => {
|
|
562
|
-
const rotationInputElement =
|
|
631
|
+
const rotationInputElement = this._getElement('rotationLeftInput');
|
|
563
632
|
let step = this.options.rotationStep;
|
|
564
633
|
if (rotationInputElement) {
|
|
565
634
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
566
635
|
if (!isNaN(parsedStep)) step = parsedStep;
|
|
567
636
|
}
|
|
568
|
-
this.rotateImage(this.currentRotation - step);
|
|
637
|
+
this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
|
|
569
638
|
});
|
|
570
639
|
this._bindIfExists('rotateRightBtn', 'click', () => {
|
|
571
|
-
const rotationInputElement =
|
|
640
|
+
const rotationInputElement = this._getElement('rotationRightInput');
|
|
572
641
|
let step = this.options.rotationStep;
|
|
573
642
|
if (rotationInputElement) {
|
|
574
643
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
575
644
|
if (!isNaN(parsedStep)) step = parsedStep;
|
|
576
645
|
}
|
|
577
|
-
this.rotateImage(this.currentRotation + step);
|
|
646
|
+
this.rotateImage(this.currentRotation + step).catch(error => this._reportError('rotateImage failed', error));
|
|
578
647
|
});
|
|
579
648
|
|
|
580
649
|
// Crop bindings (optional: bound only if element IDs exist in elements)
|
|
581
650
|
this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
|
|
582
651
|
this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
|
|
583
652
|
this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
|
|
653
|
+
this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
|
|
584
654
|
}
|
|
585
655
|
|
|
586
656
|
/**
|
|
@@ -592,7 +662,7 @@ function ensureFabric() {
|
|
|
592
662
|
* @private
|
|
593
663
|
*/
|
|
594
664
|
_bindIfExists(key, eventName, handler) {
|
|
595
|
-
const element =
|
|
665
|
+
const element = this._getElement(key);
|
|
596
666
|
if (element) {
|
|
597
667
|
element.addEventListener(eventName, handler);
|
|
598
668
|
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
@@ -605,14 +675,37 @@ function ensureFabric() {
|
|
|
605
675
|
* Reads an image File as a data URL and loads it into the Fabric canvas.
|
|
606
676
|
*
|
|
607
677
|
* @param {File} file - Image file selected by the user.
|
|
678
|
+
* @returns {Promise<void>} Resolves after the selected file is loaded.
|
|
608
679
|
* @private
|
|
609
680
|
*/
|
|
610
681
|
_loadImageFile(file) {
|
|
611
|
-
if (!
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
682
|
+
if (!this._isSupportedImageFile(file)) {
|
|
683
|
+
const error = new Error('Selected file is not a supported image');
|
|
684
|
+
this._reportError('Selected file is not a supported image', error);
|
|
685
|
+
return Promise.reject(error);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return new Promise((resolve, reject) => {
|
|
689
|
+
const reader = new FileReader();
|
|
690
|
+
reader.onload = (event) => {
|
|
691
|
+
this.loadImage(event.target.result)
|
|
692
|
+
.then(resolve)
|
|
693
|
+
.catch(reject);
|
|
694
|
+
};
|
|
695
|
+
reader.onerror = (event) => {
|
|
696
|
+
const error = new Error('Image file could not be read');
|
|
697
|
+
this._reportError('Image file could not be read', event);
|
|
698
|
+
reject(error);
|
|
699
|
+
};
|
|
700
|
+
reader.readAsDataURL(file);
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_isSupportedImageFile(file) {
|
|
705
|
+
if (!file) return false;
|
|
706
|
+
if (typeof file.type === 'string' && file.type.startsWith('image/')) return true;
|
|
707
|
+
const fileName = String(file.name || '');
|
|
708
|
+
return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
|
|
616
709
|
}
|
|
617
710
|
|
|
618
711
|
/**
|
|
@@ -645,120 +738,113 @@ function ensureFabric() {
|
|
|
645
738
|
*/
|
|
646
739
|
async loadImage(imageBase64, options = {}) {
|
|
647
740
|
if (!this._fabricLoaded) return;
|
|
648
|
-
if (!this.canvas) return;
|
|
741
|
+
if (!this.canvas || this._disposed) return;
|
|
649
742
|
if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
|
|
743
|
+
this._assertIdleForOperation('loadImage');
|
|
650
744
|
|
|
651
745
|
this._warnOnImageLayoutOptionConflict();
|
|
652
|
-
this.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
746
|
+
const transaction = this._captureLoadImageTransaction();
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const imageElement = await this._createImageElement(imageBase64);
|
|
750
|
+
if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
|
|
751
|
+
|
|
752
|
+
let loadSource = imageBase64;
|
|
753
|
+
if (this.options.downsampleOnLoad) {
|
|
754
|
+
const shouldResize =
|
|
755
|
+
imageElement.naturalWidth > this.options.downsampleMaxWidth ||
|
|
756
|
+
imageElement.naturalHeight > this.options.downsampleMaxHeight;
|
|
757
|
+
if (shouldResize) {
|
|
758
|
+
const ratio = Math.min(
|
|
759
|
+
this.options.downsampleMaxWidth / imageElement.naturalWidth,
|
|
760
|
+
this.options.downsampleMaxHeight / imageElement.naturalHeight
|
|
761
|
+
);
|
|
762
|
+
const targetWidth = Math.round(imageElement.naturalWidth * ratio);
|
|
763
|
+
const targetHeight = Math.round(imageElement.naturalHeight * ratio);
|
|
764
|
+
loadSource = this._resampleImageToDataURL(
|
|
765
|
+
imageElement,
|
|
766
|
+
targetWidth,
|
|
767
|
+
targetHeight,
|
|
768
|
+
this.options.downsampleQuality,
|
|
769
|
+
imageBase64
|
|
770
|
+
);
|
|
771
|
+
}
|
|
670
772
|
}
|
|
671
|
-
}
|
|
672
773
|
|
|
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
|
-
}
|
|
774
|
+
const fabricImage = await this._createFabricImageFromURL(loadSource);
|
|
775
|
+
if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
|
|
776
|
+
|
|
777
|
+
this.canvas.discardActiveObject();
|
|
778
|
+
this._hideAllMaskLabels();
|
|
779
|
+
this.canvas.clear();
|
|
780
|
+
this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
|
|
781
|
+
|
|
782
|
+
fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
|
|
783
|
+
this._setPlaceholderVisible(false);
|
|
784
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
785
|
+
|
|
786
|
+
const imageWidth = fabricImage.width;
|
|
787
|
+
const imageHeight = fabricImage.height;
|
|
788
|
+
|
|
789
|
+
const viewport = this._getContainerViewportSize();
|
|
790
|
+
const minWidth = viewport.width;
|
|
791
|
+
const minHeight = viewport.height;
|
|
792
|
+
|
|
793
|
+
if (this.options.fitImageToCanvas) {
|
|
794
|
+
const canvasWidth = Math.max(1, minWidth - 1);
|
|
795
|
+
const canvasHeight = Math.max(1, minHeight - 1);
|
|
796
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
797
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
798
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
799
|
+
fabricImage.scale(fitScale);
|
|
800
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
801
|
+
} else if (this.options.coverImageToCanvas) {
|
|
802
|
+
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
803
|
+
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
804
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
805
|
+
fabricImage.scale(layout.scale);
|
|
806
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
807
|
+
} else if (this.options.expandCanvasToImage) {
|
|
808
|
+
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
809
|
+
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
810
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
811
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
812
|
+
fabricImage.scale(1);
|
|
813
|
+
this.baseImageScale = 1;
|
|
814
|
+
} else {
|
|
815
|
+
const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
|
|
816
|
+
const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
|
|
817
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
818
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
819
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
820
|
+
fabricImage.scale(fitScale);
|
|
821
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
822
|
+
}
|
|
751
823
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
824
|
+
this.originalImage = fabricImage;
|
|
825
|
+
this.canvas.add(fabricImage);
|
|
826
|
+
this.canvas.sendToBack(fabricImage);
|
|
755
827
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
828
|
+
this._clearMaskPlacementMemory();
|
|
829
|
+
if (options.resetMaskCounter !== false) this.maskCounter = 0;
|
|
830
|
+
this.currentScale = 1;
|
|
831
|
+
this.currentRotation = 0;
|
|
832
|
+
|
|
833
|
+
// this._setPlaceholderVisible(false);
|
|
834
|
+
this._updateInputs();
|
|
835
|
+
this._updateMaskList();
|
|
836
|
+
this.isImageLoadedToCanvas = true;
|
|
837
|
+
this._updateUI();
|
|
838
|
+
this.canvas.renderAll();
|
|
839
|
+
this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
|
|
840
|
+
|
|
841
|
+
if (typeof this.onImageLoaded === 'function') {
|
|
842
|
+
this.onImageLoaded();
|
|
843
|
+
}
|
|
844
|
+
} catch (error) {
|
|
845
|
+
await this._rollbackLoadImageTransaction(transaction);
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
762
848
|
}
|
|
763
849
|
|
|
764
850
|
/**
|
|
@@ -810,24 +896,142 @@ function ensureFabric() {
|
|
|
810
896
|
});
|
|
811
897
|
}
|
|
812
898
|
|
|
899
|
+
_createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
900
|
+
return new Promise((resolve, reject) => {
|
|
901
|
+
const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
|
|
902
|
+
let isSettled = false;
|
|
903
|
+
let timerId;
|
|
904
|
+
const settle = (callback) => {
|
|
905
|
+
if (isSettled) return;
|
|
906
|
+
isSettled = true;
|
|
907
|
+
clearTimeout(timerId);
|
|
908
|
+
callback();
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
timerId = setTimeout(() => {
|
|
912
|
+
settle(() => reject(new Error('Fabric image load timed out')));
|
|
913
|
+
}, safeTimeoutMs);
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
fabric.Image.fromURL(dataUrl, (fabricImage) => {
|
|
917
|
+
settle(() => {
|
|
918
|
+
if (!fabricImage) {
|
|
919
|
+
reject(new Error('Image could not be loaded'));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
resolve(fabricImage);
|
|
923
|
+
});
|
|
924
|
+
}, { crossOrigin: 'anonymous' });
|
|
925
|
+
} catch (error) {
|
|
926
|
+
settle(() => reject(error));
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
_getSafeTimeoutMs(timeoutMs) {
|
|
932
|
+
const safeTimeoutMs = Number(timeoutMs);
|
|
933
|
+
return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 30000;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
_captureLoadImageTransaction() {
|
|
937
|
+
return {
|
|
938
|
+
canvasState: this._serializeCanvasState(),
|
|
939
|
+
originalImage: this.originalImage,
|
|
940
|
+
baseImageScale: this.baseImageScale,
|
|
941
|
+
currentScale: this.currentScale,
|
|
942
|
+
currentRotation: this.currentRotation,
|
|
943
|
+
maskCounter: this.maskCounter,
|
|
944
|
+
isImageLoadedToCanvas: this.isImageLoadedToCanvas,
|
|
945
|
+
lastSnapshot: this._lastSnapshot,
|
|
946
|
+
lastMask: this._lastMask,
|
|
947
|
+
lastMaskInitialLeft: this._lastMaskInitialLeft,
|
|
948
|
+
lastMaskInitialTop: this._lastMaskInitialTop,
|
|
949
|
+
lastMaskInitialWidth: this._lastMaskInitialWidth,
|
|
950
|
+
containerOverflow: this.containerElement && this.containerElement.style ? {
|
|
951
|
+
overflow: this.containerElement.style.overflow || '',
|
|
952
|
+
overflowX: this.containerElement.style.overflowX || '',
|
|
953
|
+
overflowY: this.containerElement.style.overflowY || ''
|
|
954
|
+
} : null,
|
|
955
|
+
scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
|
|
956
|
+
scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
|
|
957
|
+
placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
|
|
958
|
+
canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async _rollbackLoadImageTransaction(transaction) {
|
|
963
|
+
if (!transaction || !this.canvas || this._disposed) return;
|
|
964
|
+
try {
|
|
965
|
+
if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
this._reportError('loadImage rollback failed', error);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
this.baseImageScale = transaction.baseImageScale;
|
|
971
|
+
this.currentScale = transaction.currentScale;
|
|
972
|
+
this.currentRotation = transaction.currentRotation;
|
|
973
|
+
this.maskCounter = transaction.maskCounter;
|
|
974
|
+
this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
|
|
975
|
+
this._lastSnapshot = transaction.lastSnapshot;
|
|
976
|
+
this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
|
|
977
|
+
this._lastMaskInitialTop = transaction.lastMaskInitialTop;
|
|
978
|
+
this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
|
|
979
|
+
this._containerOriginalOverflow = transaction.containerOverflow;
|
|
980
|
+
this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
|
|
981
|
+
this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
|
|
982
|
+
if (this.containerElement) {
|
|
983
|
+
this.containerElement.scrollLeft = transaction.scrollLeft;
|
|
984
|
+
this.containerElement.scrollTop = transaction.scrollTop;
|
|
985
|
+
this._restoreContainerOverflowState();
|
|
986
|
+
}
|
|
987
|
+
this._updateInputs();
|
|
988
|
+
this._updateMaskList();
|
|
989
|
+
this._updateUI();
|
|
990
|
+
if (this.canvas) this.canvas.renderAll();
|
|
991
|
+
}
|
|
992
|
+
|
|
813
993
|
/**
|
|
814
|
-
* Resamples the given image element to a new width and height and returns the result as a
|
|
994
|
+
* Resamples the given image element to a new width and height and returns the result as a data URL.
|
|
815
995
|
*
|
|
816
996
|
* @param {HTMLImageElement} imageElement - The image element to resample.
|
|
817
997
|
* @param {number} targetWidth - Target width (in pixels) for the resampled image.
|
|
818
998
|
* @param {number} targetHeight - Target height (in pixels) for the resampled image.
|
|
819
|
-
* @param {number} [quality=0.92] -
|
|
820
|
-
* @
|
|
999
|
+
* @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1000
|
+
* @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
|
|
1001
|
+
* @returns {string} A data URL representing the resampled image.
|
|
821
1002
|
* @private
|
|
822
1003
|
*/
|
|
823
|
-
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
|
|
1004
|
+
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
|
|
824
1005
|
const offscreenCanvas = document.createElement('canvas');
|
|
825
1006
|
offscreenCanvas.width = targetWidth;
|
|
826
1007
|
offscreenCanvas.height = targetHeight;
|
|
827
1008
|
const context = offscreenCanvas.getContext('2d');
|
|
828
1009
|
if (!context) throw new Error('2D canvas context is unavailable');
|
|
829
1010
|
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
830
|
-
return offscreenCanvas.toDataURL(
|
|
1011
|
+
return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
_getDataUrlMimeType(dataUrl) {
|
|
1015
|
+
const match = String(dataUrl || '').match(/^data:([^;,]+)[;,]/i);
|
|
1016
|
+
return match ? match[1].toLowerCase() : '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
_getDownsampleMimeType(sourceDataUrl) {
|
|
1020
|
+
if (this.options.downsampleMimeType) {
|
|
1021
|
+
const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
|
|
1022
|
+
return `image/${requestedFormat}`;
|
|
1023
|
+
}
|
|
1024
|
+
const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
|
|
1025
|
+
if (this.options.preserveSourceFormat !== false && (sourceMimeType === 'image/png' || sourceMimeType === 'image/webp')) {
|
|
1026
|
+
return sourceMimeType;
|
|
1027
|
+
}
|
|
1028
|
+
return 'image/jpeg';
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
_captureCanvasStateOrThrow(context) {
|
|
1032
|
+
const snapshot = this._serializeCanvasState();
|
|
1033
|
+
if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
|
|
1034
|
+
return snapshot;
|
|
831
1035
|
}
|
|
832
1036
|
|
|
833
1037
|
/**
|
|
@@ -849,7 +1053,6 @@ function ensureFabric() {
|
|
|
849
1053
|
if (this.canvasElement) {
|
|
850
1054
|
this.canvasElement.style.width = integerWidth + 'px';
|
|
851
1055
|
this.canvasElement.style.height = integerHeight + 'px';
|
|
852
|
-
this.canvasElement.style.maxWidth = 'none';
|
|
853
1056
|
}
|
|
854
1057
|
}
|
|
855
1058
|
|
|
@@ -868,21 +1071,42 @@ function ensureFabric() {
|
|
|
868
1071
|
};
|
|
869
1072
|
}
|
|
870
1073
|
|
|
1074
|
+
const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
|
|
1075
|
+
const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
|
|
1076
|
+
let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
|
|
1077
|
+
let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
|
|
1078
|
+
|
|
1079
|
+
if (measuredWidth > 0 && measuredHeight > 0) {
|
|
1080
|
+
this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
871
1083
|
if (this._hasFixedContainerScrollbars()) {
|
|
872
|
-
return {
|
|
873
|
-
width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
|
|
874
|
-
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
875
|
-
};
|
|
1084
|
+
return { width, height };
|
|
876
1085
|
}
|
|
877
1086
|
|
|
878
|
-
const
|
|
879
|
-
const
|
|
1087
|
+
const overflow = this._getContainerOverflowValues();
|
|
1088
|
+
const canScrollX = overflow.x.some(value => value === 'auto' || value === 'scroll');
|
|
1089
|
+
const canScrollY = overflow.y.some(value => value === 'auto' || value === 'scroll');
|
|
1090
|
+
const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
|
|
1091
|
+
const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
|
|
1092
|
+
|
|
1093
|
+
if (hasHorizontalScrollbar || hasVerticalScrollbar) {
|
|
1094
|
+
const scrollbar = this._getScrollbarSize();
|
|
1095
|
+
if (hasVerticalScrollbar) width += scrollbar.width;
|
|
1096
|
+
if (hasHorizontalScrollbar) height += scrollbar.height;
|
|
1097
|
+
}
|
|
880
1098
|
|
|
881
1099
|
return { width, height };
|
|
882
1100
|
}
|
|
883
1101
|
|
|
884
|
-
|
|
885
|
-
|
|
1102
|
+
/**
|
|
1103
|
+
* Reads inline and computed overflow values for both scroll axes.
|
|
1104
|
+
*
|
|
1105
|
+
* @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
|
|
1106
|
+
* @private
|
|
1107
|
+
*/
|
|
1108
|
+
_getContainerOverflowValues() {
|
|
1109
|
+
if (!this.containerElement) return { x: [], y: [] };
|
|
886
1110
|
const inlineOverflow = this.containerElement.style.overflow;
|
|
887
1111
|
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
888
1112
|
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
@@ -897,8 +1121,16 @@ function ensureFabric() {
|
|
|
897
1121
|
computedOverflowY = style.overflowY;
|
|
898
1122
|
}
|
|
899
1123
|
|
|
900
|
-
return
|
|
901
|
-
|
|
1124
|
+
return {
|
|
1125
|
+
x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
|
|
1126
|
+
y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
_hasFixedContainerScrollbars() {
|
|
1131
|
+
if (!this.containerElement) return false;
|
|
1132
|
+
const overflow = this._getContainerOverflowValues();
|
|
1133
|
+
return [...overflow.x, ...overflow.y].some(value => value === 'scroll');
|
|
902
1134
|
}
|
|
903
1135
|
|
|
904
1136
|
_getScrollbarSize() {
|
|
@@ -948,8 +1180,8 @@ function ensureFabric() {
|
|
|
948
1180
|
const scrollbar = this._getScrollbarSize();
|
|
949
1181
|
let hasVertical = false;
|
|
950
1182
|
let hasHorizontal = false;
|
|
951
|
-
let effectiveWidth
|
|
952
|
-
let effectiveHeight
|
|
1183
|
+
let effectiveWidth;
|
|
1184
|
+
let effectiveHeight;
|
|
953
1185
|
|
|
954
1186
|
for (let i = 0; i < 4; i += 1) {
|
|
955
1187
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
@@ -1000,8 +1232,8 @@ function ensureFabric() {
|
|
|
1000
1232
|
let scale = 1;
|
|
1001
1233
|
let contentWidth = imageWidth;
|
|
1002
1234
|
let contentHeight = imageHeight;
|
|
1003
|
-
let effectiveWidth
|
|
1004
|
-
let effectiveHeight
|
|
1235
|
+
let effectiveWidth;
|
|
1236
|
+
let effectiveHeight;
|
|
1005
1237
|
|
|
1006
1238
|
for (let i = 0; i < 4; i += 1) {
|
|
1007
1239
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
@@ -1062,26 +1294,36 @@ function ensureFabric() {
|
|
|
1062
1294
|
_withNormalizedMaskStyles(callback) {
|
|
1063
1295
|
if (!this.canvas) return callback();
|
|
1064
1296
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1065
|
-
const maskStyleBackups =
|
|
1066
|
-
object: mask,
|
|
1067
|
-
stroke: mask.stroke,
|
|
1068
|
-
strokeWidth: mask.strokeWidth,
|
|
1069
|
-
opacity: mask.opacity
|
|
1070
|
-
}));
|
|
1297
|
+
const maskStyleBackups = [];
|
|
1071
1298
|
|
|
1072
1299
|
try {
|
|
1073
1300
|
masks.forEach(mask => {
|
|
1074
|
-
|
|
1301
|
+
const normalStyle = this._getMaskNormalStyle(mask);
|
|
1302
|
+
const stylePatch = {};
|
|
1303
|
+
Object.keys(normalStyle).forEach(property => {
|
|
1304
|
+
if (mask[property] !== normalStyle[property]) {
|
|
1305
|
+
stylePatch[property] = normalStyle[property];
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
const changedProperties = Object.keys(stylePatch);
|
|
1309
|
+
if (!changedProperties.length) return;
|
|
1310
|
+
|
|
1311
|
+
const backup = { object: mask };
|
|
1312
|
+
changedProperties.forEach(property => {
|
|
1313
|
+
backup[property] = mask[property];
|
|
1314
|
+
});
|
|
1315
|
+
maskStyleBackups.push(backup);
|
|
1316
|
+
mask.set(stylePatch);
|
|
1075
1317
|
});
|
|
1076
1318
|
return callback();
|
|
1077
1319
|
} finally {
|
|
1078
1320
|
maskStyleBackups.forEach(backup => {
|
|
1079
1321
|
try {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
opacity: backup.opacity
|
|
1322
|
+
const restorePatch = {};
|
|
1323
|
+
Object.keys(backup).forEach(property => {
|
|
1324
|
+
if (property !== 'object') restorePatch[property] = backup[property];
|
|
1084
1325
|
});
|
|
1326
|
+
backup.object.set(restorePatch);
|
|
1085
1327
|
} catch (error) { void error; }
|
|
1086
1328
|
});
|
|
1087
1329
|
}
|
|
@@ -1233,6 +1475,7 @@ function ensureFabric() {
|
|
|
1233
1475
|
};
|
|
1234
1476
|
timerId = setTimeout(() => {
|
|
1235
1477
|
settle(() => reject(new Error('Image crop load timed out')));
|
|
1478
|
+
// Clearing src prevents later network/decode work; already-dispatched onload work is guarded by settle().
|
|
1236
1479
|
try { imageElement.src = ''; } catch (error) { void error; }
|
|
1237
1480
|
}, safeTimeoutMs);
|
|
1238
1481
|
imageElement.onload = () => {
|
|
@@ -1260,7 +1503,7 @@ function ensureFabric() {
|
|
|
1260
1503
|
}
|
|
1261
1504
|
|
|
1262
1505
|
/**
|
|
1263
|
-
* Exports
|
|
1506
|
+
* Exports a source region directly through Fabric's region export options.
|
|
1264
1507
|
*
|
|
1265
1508
|
* @param {Object} region - Canvas source region and export options.
|
|
1266
1509
|
* @param {number} region.sourceX - Source region x coordinate.
|
|
@@ -1273,15 +1516,17 @@ function ensureFabric() {
|
|
|
1273
1516
|
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1274
1517
|
* @private
|
|
1275
1518
|
*/
|
|
1276
|
-
|
|
1519
|
+
_exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
|
|
1277
1520
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1278
|
-
|
|
1521
|
+
return this.canvas.toDataURL({
|
|
1279
1522
|
format,
|
|
1280
1523
|
quality,
|
|
1281
|
-
multiplier: safeMultiplier
|
|
1524
|
+
multiplier: safeMultiplier,
|
|
1525
|
+
left: sourceX,
|
|
1526
|
+
top: sourceY,
|
|
1527
|
+
width: sourceWidth,
|
|
1528
|
+
height: sourceHeight
|
|
1282
1529
|
});
|
|
1283
|
-
|
|
1284
|
-
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
1285
1530
|
}
|
|
1286
1531
|
|
|
1287
1532
|
/**
|
|
@@ -1295,12 +1540,41 @@ function ensureFabric() {
|
|
|
1295
1540
|
_getObjectTopLeftPoint(fabricObject) {
|
|
1296
1541
|
if (!fabricObject) return { x: 0, y: 0 };
|
|
1297
1542
|
fabricObject.setCoords();
|
|
1298
|
-
const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
|
|
1299
|
-
if (coords && coords.length) return coords[0];
|
|
1300
1543
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1301
1544
|
return { x: boundingRect.left, y: boundingRect.top };
|
|
1302
1545
|
}
|
|
1303
1546
|
|
|
1547
|
+
_getObjectCoordinateTopLeftPoint(fabricObject) {
|
|
1548
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
1549
|
+
fabricObject.setCoords();
|
|
1550
|
+
const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
|
|
1551
|
+
if (coords && coords.length) return coords[0];
|
|
1552
|
+
return this._getObjectTopLeftPoint(fabricObject);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
_getObjectOriginPoint(fabricObject, originX, originY) {
|
|
1556
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
1557
|
+
if (typeof fabricObject.getPointByOrigin === 'function') {
|
|
1558
|
+
return fabricObject.getPointByOrigin(originX, originY);
|
|
1559
|
+
}
|
|
1560
|
+
return this._getObjectTopLeftPoint(fabricObject);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
_translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
|
|
1564
|
+
if (!fabricObject) return;
|
|
1565
|
+
if (typeof fabricObject.getCenterPoint === 'function' && typeof fabricObject.setPositionByOrigin === 'function') {
|
|
1566
|
+
const center = fabricObject.getCenterPoint();
|
|
1567
|
+
const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
|
|
1568
|
+
fabricObject.setPositionByOrigin(nextCenter, 'center', 'center');
|
|
1569
|
+
} else {
|
|
1570
|
+
fabricObject.set({
|
|
1571
|
+
left: (fabricObject.left || 0) + deltaX,
|
|
1572
|
+
top: (fabricObject.top || 0) + deltaY
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
fabricObject.setCoords();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1304
1578
|
/**
|
|
1305
1579
|
* Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
|
|
1306
1580
|
*
|
|
@@ -1369,8 +1643,10 @@ function ensureFabric() {
|
|
|
1369
1643
|
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1370
1644
|
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
|
|
1371
1645
|
try {
|
|
1372
|
-
|
|
1373
|
-
|
|
1646
|
+
const currentWidth = this.canvas.getWidth();
|
|
1647
|
+
const currentHeight = this.canvas.getHeight();
|
|
1648
|
+
let requiredWidth = currentWidth;
|
|
1649
|
+
let requiredHeight = currentHeight;
|
|
1374
1650
|
fabricObjects.forEach(fabricObject => {
|
|
1375
1651
|
if (!fabricObject) return;
|
|
1376
1652
|
if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
|
|
@@ -1378,11 +1654,23 @@ function ensureFabric() {
|
|
|
1378
1654
|
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1379
1655
|
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1380
1656
|
});
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (
|
|
1657
|
+
const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
|
|
1658
|
+
|
|
1659
|
+
let minWidth = 0;
|
|
1660
|
+
let minHeight = 0;
|
|
1661
|
+
if (shouldUseScrollSafeViewport) {
|
|
1662
|
+
const viewport = this._getContainerViewportSize();
|
|
1663
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
1664
|
+
|
|
1665
|
+
minWidth = Math.max(1, viewport.width - safetyMargin);
|
|
1666
|
+
minHeight = Math.max(1, viewport.height - safetyMargin);
|
|
1667
|
+
} else if (this.containerElement) {
|
|
1668
|
+
minWidth = Math.floor(this.containerElement.clientWidth || 0);
|
|
1669
|
+
minHeight = Math.floor(this.containerElement.clientHeight || 0);
|
|
1670
|
+
}
|
|
1671
|
+
const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
|
|
1672
|
+
const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
|
|
1673
|
+
if (newWidth !== currentWidth || newHeight !== currentHeight) {
|
|
1386
1674
|
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1387
1675
|
}
|
|
1388
1676
|
} catch (error) {
|
|
@@ -1413,6 +1701,69 @@ function ensureFabric() {
|
|
|
1413
1701
|
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
1414
1702
|
}
|
|
1415
1703
|
|
|
1704
|
+
_assertIdleForOperation(operationName) {
|
|
1705
|
+
if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
|
|
1706
|
+
if (this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) {
|
|
1707
|
+
throw new Error(`${operationName} cannot run while an animation is running`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
_canMutateNow(operationName) {
|
|
1712
|
+
try {
|
|
1713
|
+
this._assertIdleForOperation(operationName);
|
|
1714
|
+
return true;
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
this._reportError(`${operationName} blocked`, error);
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
_rejectActiveAnimations(reason) {
|
|
1722
|
+
const error = reason instanceof Error ? reason : new Error(String(reason || 'Animation cancelled'));
|
|
1723
|
+
this._activeAnimationRejectors.forEach(reject => {
|
|
1724
|
+
try { reject(error); } catch (rejectError) { void rejectError; }
|
|
1725
|
+
});
|
|
1726
|
+
this._activeAnimationRejectors.clear();
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
_animateFabricProperty(fabricObject, property, value) {
|
|
1730
|
+
return new Promise((resolve, reject) => {
|
|
1731
|
+
if (this._disposed || !this.canvas || !fabricObject) {
|
|
1732
|
+
reject(new Error('Animation cannot start after editor disposal'));
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
let isSettled = false;
|
|
1737
|
+
const duration = Math.max(0, Number(this.options.animationDuration) || 0);
|
|
1738
|
+
const timeoutMs = Math.max(1000, duration + 1000);
|
|
1739
|
+
let timerId;
|
|
1740
|
+
const settle = (callback) => {
|
|
1741
|
+
if (isSettled) return;
|
|
1742
|
+
isSettled = true;
|
|
1743
|
+
clearTimeout(timerId);
|
|
1744
|
+
this._activeAnimationRejectors.delete(reject);
|
|
1745
|
+
callback();
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
this._activeAnimationRejectors.add(reject);
|
|
1749
|
+
timerId = setTimeout(() => {
|
|
1750
|
+
settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
|
|
1751
|
+
}, timeoutMs);
|
|
1752
|
+
|
|
1753
|
+
try {
|
|
1754
|
+
fabricObject.animate(property, value, {
|
|
1755
|
+
duration,
|
|
1756
|
+
onChange: () => {
|
|
1757
|
+
if (!this._disposed && this.canvas) this.canvas.renderAll();
|
|
1758
|
+
},
|
|
1759
|
+
onComplete: () => settle(resolve)
|
|
1760
|
+
});
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
settle(() => reject(error));
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1416
1767
|
/**
|
|
1417
1768
|
* Scales the original image by a given factor, with animation.
|
|
1418
1769
|
* Returns a promise that resolves when the scale animation is complete.
|
|
@@ -1420,37 +1771,29 @@ function ensureFabric() {
|
|
|
1420
1771
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
1421
1772
|
* @private
|
|
1422
1773
|
*/
|
|
1423
|
-
_scaleImageImpl(factor, options = {}) {
|
|
1424
|
-
if (!this.originalImage
|
|
1425
|
-
if (this.isAnimating) return
|
|
1774
|
+
async _scaleImageImpl(factor, options = {}) {
|
|
1775
|
+
if (!this.originalImage || this._disposed) return;
|
|
1776
|
+
if (this.isAnimating) return;
|
|
1426
1777
|
const saveHistory = options.saveHistory !== false;
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1778
|
+
let didStartAnimation = false;
|
|
1779
|
+
try {
|
|
1780
|
+
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
1781
|
+
this.currentScale = factor;
|
|
1782
|
+
this.isAnimating = true;
|
|
1783
|
+
didStartAnimation = true;
|
|
1784
|
+
this._updateUI();
|
|
1431
1785
|
|
|
1432
|
-
|
|
1786
|
+
const targetScale = this.baseImageScale * factor;
|
|
1433
1787
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
|
|
1788
|
+
const topLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
1789
|
+
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
|
|
1437
1790
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
});
|
|
1444
|
-
});
|
|
1445
|
-
const scaleYAnimation = new Promise((resolve) => {
|
|
1446
|
-
this.originalImage.animate('scaleY', targetScale, {
|
|
1447
|
-
duration: this.options.animationDuration,
|
|
1448
|
-
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
1449
|
-
onComplete: resolve
|
|
1450
|
-
});
|
|
1451
|
-
});
|
|
1791
|
+
await Promise.all([
|
|
1792
|
+
this._animateFabricProperty(this.originalImage, 'scaleX', targetScale),
|
|
1793
|
+
this._animateFabricProperty(this.originalImage, 'scaleY', targetScale)
|
|
1794
|
+
]);
|
|
1795
|
+
if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during scale animation');
|
|
1452
1796
|
|
|
1453
|
-
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1454
1797
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1455
1798
|
this.originalImage.setCoords();
|
|
1456
1799
|
|
|
@@ -1460,17 +1803,17 @@ function ensureFabric() {
|
|
|
1460
1803
|
|
|
1461
1804
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1462
1805
|
|
|
1463
|
-
// Sync mask labels
|
|
1464
1806
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
1465
1807
|
|
|
1466
|
-
this.isAnimating = false;
|
|
1467
1808
|
this._updateInputs();
|
|
1468
|
-
this._updateUI();
|
|
1469
1809
|
if (saveHistory) this.saveState();
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1810
|
+
} finally {
|
|
1811
|
+
if (didStartAnimation) {
|
|
1812
|
+
this.isAnimating = false;
|
|
1813
|
+
this._updateInputs();
|
|
1814
|
+
this._updateUI();
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1474
1817
|
}
|
|
1475
1818
|
|
|
1476
1819
|
/**
|
|
@@ -1491,27 +1834,29 @@ function ensureFabric() {
|
|
|
1491
1834
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
1492
1835
|
* @private
|
|
1493
1836
|
*/
|
|
1494
|
-
_rotateImageImpl(degrees, options = {}) {
|
|
1495
|
-
if (!this.originalImage
|
|
1496
|
-
if (this.isAnimating) return
|
|
1497
|
-
if (isNaN(degrees)) return
|
|
1837
|
+
async _rotateImageImpl(degrees, options = {}) {
|
|
1838
|
+
if (!this.originalImage || this._disposed) return;
|
|
1839
|
+
if (this.isAnimating) return;
|
|
1840
|
+
if (isNaN(degrees)) return;
|
|
1498
1841
|
const saveHistory = options.saveHistory !== false;
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1842
|
+
const image = this.originalImage;
|
|
1843
|
+
const previousOriginX = image.originX || 'left';
|
|
1844
|
+
const previousOriginY = image.originY || 'top';
|
|
1845
|
+
const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
|
|
1846
|
+
let didStartAnimation = false;
|
|
1847
|
+
let didCompleteRotation = false;
|
|
1848
|
+
try {
|
|
1849
|
+
this.currentRotation = degrees;
|
|
1850
|
+
this.isAnimating = true;
|
|
1851
|
+
didStartAnimation = true;
|
|
1852
|
+
this._updateUI();
|
|
1502
1853
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1854
|
+
const center = image.getCenterPoint();
|
|
1855
|
+
this._setObjectOriginKeepingPosition(image, 'center', 'center', center);
|
|
1505
1856
|
|
|
1506
|
-
|
|
1507
|
-
this.originalImage
|
|
1508
|
-
duration: this.options.animationDuration,
|
|
1509
|
-
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
1510
|
-
onComplete: resolve
|
|
1511
|
-
});
|
|
1512
|
-
});
|
|
1857
|
+
await this._animateFabricProperty(image, 'angle', degrees);
|
|
1858
|
+
if (this._disposed || !this.canvas || !this.originalImage) throw new Error('Editor was disposed during rotation animation');
|
|
1513
1859
|
|
|
1514
|
-
return rotationAnimation.then(() => {
|
|
1515
1860
|
this.originalImage.set('angle', degrees);
|
|
1516
1861
|
this.originalImage.setCoords();
|
|
1517
1862
|
|
|
@@ -1521,20 +1866,24 @@ function ensureFabric() {
|
|
|
1521
1866
|
|
|
1522
1867
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1523
1868
|
|
|
1524
|
-
const newTopLeft = this.
|
|
1869
|
+
const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
|
|
1525
1870
|
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
|
|
1526
1871
|
|
|
1527
|
-
// Sync mask labels
|
|
1528
1872
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
1529
1873
|
|
|
1530
|
-
this.isAnimating = false;
|
|
1531
1874
|
this._updateInputs();
|
|
1532
|
-
this._updateUI();
|
|
1533
1875
|
if (saveHistory) this.saveState();
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
this.
|
|
1537
|
-
|
|
1876
|
+
didCompleteRotation = true;
|
|
1877
|
+
} finally {
|
|
1878
|
+
if (!didCompleteRotation && !this._disposed && image) {
|
|
1879
|
+
this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
|
|
1880
|
+
}
|
|
1881
|
+
if (didStartAnimation) {
|
|
1882
|
+
this.isAnimating = false;
|
|
1883
|
+
this._updateInputs();
|
|
1884
|
+
this._updateUI();
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1538
1887
|
}
|
|
1539
1888
|
|
|
1540
1889
|
/**
|
|
@@ -1547,13 +1896,14 @@ function ensureFabric() {
|
|
|
1547
1896
|
if (!this.originalImage) return Promise.resolve();
|
|
1548
1897
|
|
|
1549
1898
|
return this.animationQueue.add(async () => {
|
|
1550
|
-
const before = this._lastSnapshot || this.
|
|
1899
|
+
const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
|
|
1551
1900
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1552
1901
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1553
|
-
const after = this.
|
|
1902
|
+
const after = this._captureCanvasStateOrThrow('resetImageTransform');
|
|
1554
1903
|
this._pushStateTransition(before, after);
|
|
1555
1904
|
}).catch(error => {
|
|
1556
1905
|
this._reportError('resetImageTransform() failed', error);
|
|
1906
|
+
throw error;
|
|
1557
1907
|
});
|
|
1558
1908
|
}
|
|
1559
1909
|
|
|
@@ -1575,17 +1925,35 @@ function ensureFabric() {
|
|
|
1575
1925
|
* @public
|
|
1576
1926
|
*/
|
|
1577
1927
|
loadFromState(serializedState) {
|
|
1578
|
-
if (!serializedState || !this.canvas) return Promise.resolve();
|
|
1928
|
+
if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
|
|
1929
|
+
if (this._cropMode || this._cropRect) {
|
|
1930
|
+
this._removeCropRect();
|
|
1931
|
+
this._restoreCropObjectState();
|
|
1932
|
+
this._cropMode = false;
|
|
1933
|
+
if (this._prevSelectionSetting !== undefined && this.canvas) {
|
|
1934
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
1935
|
+
}
|
|
1936
|
+
this._prevSelectionSetting = undefined;
|
|
1937
|
+
}
|
|
1579
1938
|
|
|
1580
|
-
return new Promise((resolve) => {
|
|
1939
|
+
return new Promise((resolve, reject) => {
|
|
1581
1940
|
try {
|
|
1582
1941
|
const state = (typeof serializedState === 'string')
|
|
1583
1942
|
? JSON.parse(serializedState)
|
|
1584
1943
|
: serializedState;
|
|
1585
1944
|
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1586
1945
|
|
|
1587
|
-
this.canvas.loadFromJSON(state, () => {
|
|
1946
|
+
this.canvas.loadFromJSON(state, async () => {
|
|
1588
1947
|
try {
|
|
1948
|
+
if (this._disposed || !this.canvas) {
|
|
1949
|
+
reject(new Error('Editor was disposed while loading state'));
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
await this._waitForFabricImagesReady(this.canvas.getObjects());
|
|
1953
|
+
if (this._disposed || !this.canvas) {
|
|
1954
|
+
reject(new Error('Editor was disposed while loading state'));
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1589
1957
|
this._hideAllMaskLabels();
|
|
1590
1958
|
const canvasObjects = this.canvas.getObjects();
|
|
1591
1959
|
this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
|
|
@@ -1644,20 +2012,48 @@ function ensureFabric() {
|
|
|
1644
2012
|
this._updatePlaceholderStatus();
|
|
1645
2013
|
this._lastSnapshot = this._serializeCanvasState();
|
|
1646
2014
|
this._updateUI();
|
|
2015
|
+
resolve();
|
|
1647
2016
|
} catch (callbackError) {
|
|
1648
2017
|
this._reportError('loadFromState() failed', callbackError);
|
|
1649
|
-
|
|
1650
|
-
resolve();
|
|
2018
|
+
reject(callbackError);
|
|
1651
2019
|
}
|
|
1652
2020
|
});
|
|
1653
2021
|
|
|
1654
2022
|
} catch (error) {
|
|
1655
2023
|
this._reportError('loadFromState() failed', error);
|
|
1656
|
-
|
|
2024
|
+
reject(error);
|
|
1657
2025
|
}
|
|
1658
2026
|
});
|
|
1659
2027
|
}
|
|
1660
2028
|
|
|
2029
|
+
async _waitForFabricImagesReady(canvasObjects) {
|
|
2030
|
+
const imageObjects = (canvasObjects || []).filter(object => object && object.type === 'image');
|
|
2031
|
+
await Promise.all(imageObjects.map(object => this._waitForImageElementReady(
|
|
2032
|
+
typeof object.getElement === 'function' ? object.getElement() : object._element
|
|
2033
|
+
)));
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
_waitForImageElementReady(imageElement) {
|
|
2037
|
+
if (!imageElement) return Promise.resolve();
|
|
2038
|
+
if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
|
|
2039
|
+
return new Promise((resolve, reject) => {
|
|
2040
|
+
let isSettled = false;
|
|
2041
|
+
const timerId = setTimeout(() => {
|
|
2042
|
+
settle(() => reject(new Error('Image load timed out while restoring state')));
|
|
2043
|
+
}, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
|
|
2044
|
+
const settle = (callback) => {
|
|
2045
|
+
if (isSettled) return;
|
|
2046
|
+
isSettled = true;
|
|
2047
|
+
clearTimeout(timerId);
|
|
2048
|
+
imageElement.onload = null;
|
|
2049
|
+
imageElement.onerror = null;
|
|
2050
|
+
callback();
|
|
2051
|
+
};
|
|
2052
|
+
imageElement.onload = () => settle(resolve);
|
|
2053
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1661
2057
|
/**
|
|
1662
2058
|
* Saves the current editable canvas state as an undoable history transition.
|
|
1663
2059
|
*
|
|
@@ -1669,10 +2065,9 @@ function ensureFabric() {
|
|
|
1669
2065
|
*/
|
|
1670
2066
|
saveState() {
|
|
1671
2067
|
if (!this.canvas) return;
|
|
1672
|
-
const activeObject = this.canvas.getActiveObject();
|
|
1673
2068
|
|
|
1674
2069
|
try {
|
|
1675
|
-
const after = this.
|
|
2070
|
+
const after = this._captureCanvasStateOrThrow('saveState');
|
|
1676
2071
|
const before = this._lastSnapshot || after;
|
|
1677
2072
|
if (after === before) return;
|
|
1678
2073
|
let executedOnce = false;
|
|
@@ -1693,9 +2088,6 @@ function ensureFabric() {
|
|
|
1693
2088
|
} catch (error) {
|
|
1694
2089
|
this._reportWarning('saveState: failed to save canvas snapshot', error);
|
|
1695
2090
|
} finally {
|
|
1696
|
-
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1697
|
-
this._handleSelectionChanged([activeObject]);
|
|
1698
|
-
}
|
|
1699
2091
|
this._updateUI();
|
|
1700
2092
|
}
|
|
1701
2093
|
}
|
|
@@ -1712,7 +2104,10 @@ function ensureFabric() {
|
|
|
1712
2104
|
* @private
|
|
1713
2105
|
*/
|
|
1714
2106
|
_pushStateTransition(before, after) {
|
|
1715
|
-
if (!before || !after)
|
|
2107
|
+
if (!before || !after) {
|
|
2108
|
+
this._reportWarning('History transition skipped because a canvas snapshot is unavailable');
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
1716
2111
|
if (before === after) return;
|
|
1717
2112
|
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1718
2113
|
|
|
@@ -1734,7 +2129,10 @@ function ensureFabric() {
|
|
|
1734
2129
|
undo() {
|
|
1735
2130
|
return this.historyManager.undo()
|
|
1736
2131
|
.then(() => { this._updateUI(); })
|
|
1737
|
-
.catch(error => {
|
|
2132
|
+
.catch(error => {
|
|
2133
|
+
this._reportError('undo failed', error);
|
|
2134
|
+
throw error;
|
|
2135
|
+
});
|
|
1738
2136
|
}
|
|
1739
2137
|
|
|
1740
2138
|
/**
|
|
@@ -1746,7 +2144,10 @@ function ensureFabric() {
|
|
|
1746
2144
|
redo() {
|
|
1747
2145
|
return this.historyManager.redo()
|
|
1748
2146
|
.then(() => { this._updateUI(); })
|
|
1749
|
-
.catch(error => {
|
|
2147
|
+
.catch(error => {
|
|
2148
|
+
this._reportError('redo failed', error);
|
|
2149
|
+
throw error;
|
|
2150
|
+
});
|
|
1750
2151
|
}
|
|
1751
2152
|
|
|
1752
2153
|
_rebindMaskEvents(mask) {
|
|
@@ -1768,23 +2169,17 @@ function ensureFabric() {
|
|
|
1768
2169
|
}
|
|
1769
2170
|
if (Object.keys(metadata).length) mask.set(metadata);
|
|
1770
2171
|
|
|
1771
|
-
const normalStyle = {
|
|
1772
|
-
stroke: mask.originalStroke || '#ccc',
|
|
1773
|
-
strokeWidth: mask.originalStrokeWidth,
|
|
1774
|
-
opacity: mask.originalAlpha
|
|
1775
|
-
};
|
|
1776
|
-
const hoverStyle = {
|
|
1777
|
-
stroke: '#ff5500',
|
|
1778
|
-
strokeWidth: 2,
|
|
1779
|
-
opacity: Math.min(mask.originalAlpha + 0.2, 1)
|
|
1780
|
-
};
|
|
1781
|
-
|
|
1782
2172
|
const mouseover = () => {
|
|
1783
|
-
mask.
|
|
2173
|
+
const opacity = Number(mask.originalAlpha);
|
|
2174
|
+
mask.set({
|
|
2175
|
+
stroke: '#ff5500',
|
|
2176
|
+
strokeWidth: 2,
|
|
2177
|
+
opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
|
|
2178
|
+
});
|
|
1784
2179
|
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1785
2180
|
};
|
|
1786
2181
|
const mouseout = () => {
|
|
1787
|
-
mask.set(
|
|
2182
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
1788
2183
|
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1789
2184
|
};
|
|
1790
2185
|
|
|
@@ -1823,6 +2218,7 @@ function ensureFabric() {
|
|
|
1823
2218
|
*/
|
|
1824
2219
|
createMask(config = {}) {
|
|
1825
2220
|
if (!this.canvas) return null;
|
|
2221
|
+
if (!this._canMutateNow('createMask')) return null;
|
|
1826
2222
|
const shapeType = config.shape || 'rect';
|
|
1827
2223
|
// Normalize mask defaults before applying caller-provided overrides.
|
|
1828
2224
|
const maskConfig = {
|
|
@@ -1841,37 +2237,43 @@ function ensureFabric() {
|
|
|
1841
2237
|
|
|
1842
2238
|
// Always start placement relative to canvas left/top.
|
|
1843
2239
|
const firstOffset = 10;
|
|
1844
|
-
let left
|
|
1845
|
-
let top
|
|
2240
|
+
let left;
|
|
2241
|
+
let top;
|
|
2242
|
+
|
|
2243
|
+
const getCanvasBasis = (axis) => {
|
|
2244
|
+
const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
|
|
2245
|
+
const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
|
|
2246
|
+
if (axis === 'height') return canvasHeight;
|
|
2247
|
+
if (axis === 'min') return Math.min(canvasWidth, canvasHeight);
|
|
2248
|
+
return canvasWidth;
|
|
2249
|
+
};
|
|
1846
2250
|
|
|
1847
|
-
const resolveValue = (value, fallback) => {
|
|
2251
|
+
const resolveValue = (value, fallback, axis = 'width') => {
|
|
1848
2252
|
if (typeof value === 'function')
|
|
1849
2253
|
return value(this.canvas, this.options);
|
|
1850
2254
|
if (typeof value === 'string' && value.endsWith('%')) {
|
|
1851
|
-
const percent = parseFloat(value) / 100;
|
|
1852
|
-
|
|
2255
|
+
const percent = Number.parseFloat(value) / 100;
|
|
2256
|
+
if (!Number.isFinite(percent)) return fallback;
|
|
2257
|
+
return Math.floor(getCanvasBasis(axis) * percent);
|
|
1853
2258
|
}
|
|
1854
2259
|
return value != null ? value : fallback;
|
|
1855
|
-
}
|
|
2260
|
+
};
|
|
1856
2261
|
|
|
1857
2262
|
if (maskConfig.left === undefined && this._lastMask) {
|
|
1858
2263
|
const previousMask = this._lastMask;
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
}
|
|
1866
|
-
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1867
|
-
top = previousMask.top ?? firstOffset;
|
|
2264
|
+
if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
|
|
2265
|
+
const previousBounds = typeof previousMask.getBoundingRect === 'function'
|
|
2266
|
+
? previousMask.getBoundingRect(true, true)
|
|
2267
|
+
: { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
|
|
2268
|
+
left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
|
|
2269
|
+
top = Math.round(previousBounds.top ?? firstOffset);
|
|
1868
2270
|
} else {
|
|
1869
|
-
left = resolveValue(maskConfig.left, firstOffset);
|
|
1870
|
-
top = resolveValue(maskConfig.top, firstOffset);
|
|
2271
|
+
left = resolveValue(maskConfig.left, firstOffset, 'width');
|
|
2272
|
+
top = resolveValue(maskConfig.top, firstOffset, 'height');
|
|
1871
2273
|
}
|
|
1872
2274
|
|
|
1873
|
-
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1874
|
-
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
2275
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
|
|
2276
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
|
|
1875
2277
|
maskConfig.left = left;
|
|
1876
2278
|
maskConfig.top = top;
|
|
1877
2279
|
|
|
@@ -1883,7 +2285,7 @@ function ensureFabric() {
|
|
|
1883
2285
|
case 'circle':
|
|
1884
2286
|
mask = new fabric.Circle({
|
|
1885
2287
|
left, top,
|
|
1886
|
-
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
2288
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
|
|
1887
2289
|
fill: maskConfig.color,
|
|
1888
2290
|
opacity: maskConfig.alpha,
|
|
1889
2291
|
angle: maskConfig.angle,
|
|
@@ -1893,8 +2295,8 @@ function ensureFabric() {
|
|
|
1893
2295
|
case 'ellipse':
|
|
1894
2296
|
mask = new fabric.Ellipse({
|
|
1895
2297
|
left, top,
|
|
1896
|
-
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1897
|
-
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
2298
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
|
|
2299
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
|
|
1898
2300
|
fill: maskConfig.color,
|
|
1899
2301
|
opacity: maskConfig.alpha,
|
|
1900
2302
|
angle: maskConfig.angle,
|
|
@@ -1922,8 +2324,8 @@ function ensureFabric() {
|
|
|
1922
2324
|
default:
|
|
1923
2325
|
mask = new fabric.Rect({
|
|
1924
2326
|
left, top,
|
|
1925
|
-
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1926
|
-
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
2327
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
|
|
2328
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
|
|
1927
2329
|
fill: maskConfig.color,
|
|
1928
2330
|
opacity: maskConfig.alpha,
|
|
1929
2331
|
angle: maskConfig.angle,
|
|
@@ -1964,7 +2366,7 @@ function ensureFabric() {
|
|
|
1964
2366
|
// Store placement values so the next mask can be positioned beside this one.
|
|
1965
2367
|
this._lastMaskInitialLeft = left;
|
|
1966
2368
|
this._lastMaskInitialTop = top;
|
|
1967
|
-
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
2369
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
|
|
1968
2370
|
|
|
1969
2371
|
const maskId = ++this.maskCounter;
|
|
1970
2372
|
mask.set({
|
|
@@ -2002,6 +2404,8 @@ function ensureFabric() {
|
|
|
2002
2404
|
* The associated label is also removed. UI and mask list are updated.
|
|
2003
2405
|
*/
|
|
2004
2406
|
removeSelectedMask() {
|
|
2407
|
+
if (!this.canvas) return;
|
|
2408
|
+
if (!this._canMutateNow('removeSelectedMask')) return;
|
|
2005
2409
|
const activeObject = this.canvas.getActiveObject();
|
|
2006
2410
|
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
2007
2411
|
if (!selectedMasks.length) return;
|
|
@@ -2030,6 +2434,8 @@ function ensureFabric() {
|
|
|
2030
2434
|
* UI and internal mask placement memory are reset.
|
|
2031
2435
|
*/
|
|
2032
2436
|
removeAllMasks(options = {}) {
|
|
2437
|
+
if (!this.canvas) return;
|
|
2438
|
+
if (!this._canMutateNow('removeAllMasks')) return;
|
|
2033
2439
|
const saveHistory = options.saveHistory !== false;
|
|
2034
2440
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2035
2441
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
@@ -2095,6 +2501,10 @@ function ensureFabric() {
|
|
|
2095
2501
|
let textObject = null;
|
|
2096
2502
|
if (this.options.label && typeof this.options.label.create === 'function') {
|
|
2097
2503
|
textObject = this.options.label.create(mask, fabric);
|
|
2504
|
+
if (!textObject || typeof textObject.set !== 'function') {
|
|
2505
|
+
this._reportWarning('label.create() returned an invalid Fabric object; using the default label');
|
|
2506
|
+
textObject = null;
|
|
2507
|
+
}
|
|
2098
2508
|
}
|
|
2099
2509
|
if (!textObject) {
|
|
2100
2510
|
let labelText = mask.maskName;
|
|
@@ -2161,10 +2571,11 @@ function ensureFabric() {
|
|
|
2161
2571
|
if (!this.options.maskLabelOnSelect) return;
|
|
2162
2572
|
if (!mask.__label) return;
|
|
2163
2573
|
|
|
2164
|
-
|
|
2165
|
-
|
|
2574
|
+
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
2575
|
+
const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
|
|
2576
|
+
if (!bounds) return;
|
|
2166
2577
|
|
|
2167
|
-
const tl =
|
|
2578
|
+
const tl = { x: bounds.left, y: bounds.top };
|
|
2168
2579
|
const center = mask.getCenterPoint();
|
|
2169
2580
|
|
|
2170
2581
|
const vx = center.x - tl.x;
|
|
@@ -2247,7 +2658,7 @@ function ensureFabric() {
|
|
|
2247
2658
|
* @private
|
|
2248
2659
|
*/
|
|
2249
2660
|
_updateMaskList() {
|
|
2250
|
-
const maskListElement =
|
|
2661
|
+
const maskListElement = this._getElement('maskList');
|
|
2251
2662
|
if (!maskListElement) return;
|
|
2252
2663
|
maskListElement.innerHTML = '';
|
|
2253
2664
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
@@ -2255,11 +2666,22 @@ function ensureFabric() {
|
|
|
2255
2666
|
const listItemElement = document.createElement('li');
|
|
2256
2667
|
listItemElement.className = 'list-group-item mask-item';
|
|
2257
2668
|
listItemElement.textContent = mask.maskName;
|
|
2258
|
-
listItemElement.
|
|
2669
|
+
listItemElement.dataset.maskId = String(mask.maskId);
|
|
2259
2670
|
maskListElement.appendChild(listItemElement);
|
|
2260
2671
|
});
|
|
2261
2672
|
}
|
|
2262
2673
|
|
|
2674
|
+
_handleMaskListClick(event) {
|
|
2675
|
+
if (!this.canvas) return;
|
|
2676
|
+
const itemElement = event.target && event.target.closest ? event.target.closest('.mask-item') : null;
|
|
2677
|
+
if (!itemElement || !itemElement.dataset) return;
|
|
2678
|
+
const maskId = Number(itemElement.dataset.maskId);
|
|
2679
|
+
const mask = this.canvas.getObjects().find(object => Number(object.maskId) === maskId);
|
|
2680
|
+
if (!mask) return;
|
|
2681
|
+
this.canvas.setActiveObject(mask);
|
|
2682
|
+
this._handleSelectionChanged([mask]);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2263
2685
|
/**
|
|
2264
2686
|
* Updates the visual selection (CSS 'active') state for the mask list in the DOM.
|
|
2265
2687
|
*
|
|
@@ -2267,12 +2689,13 @@ function ensureFabric() {
|
|
|
2267
2689
|
* @private
|
|
2268
2690
|
*/
|
|
2269
2691
|
_updateMaskListSelection(selectedMask) {
|
|
2270
|
-
const maskListElement =
|
|
2692
|
+
const maskListElement = this._getElement('maskList');
|
|
2271
2693
|
if (!maskListElement) return;
|
|
2272
2694
|
const maskItems = maskListElement.querySelectorAll('.mask-item');
|
|
2273
2695
|
maskItems.forEach(item => {
|
|
2274
|
-
const isSelected = !!selectedMask && item.
|
|
2696
|
+
const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
|
|
2275
2697
|
item.classList.toggle('active', isSelected);
|
|
2698
|
+
item.classList.toggle('selected', isSelected);
|
|
2276
2699
|
});
|
|
2277
2700
|
}
|
|
2278
2701
|
|
|
@@ -2288,6 +2711,7 @@ function ensureFabric() {
|
|
|
2288
2711
|
*/
|
|
2289
2712
|
async mergeMasks() {
|
|
2290
2713
|
if (!this.originalImage) return;
|
|
2714
|
+
this._assertIdleForOperation('mergeMasks');
|
|
2291
2715
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2292
2716
|
if (!masks.length) return;
|
|
2293
2717
|
|
|
@@ -2298,11 +2722,12 @@ function ensureFabric() {
|
|
|
2298
2722
|
const beforeJson = this._serializeCanvasState();
|
|
2299
2723
|
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
2300
2724
|
this.removeAllMasks({ saveHistory: false });
|
|
2301
|
-
await this.loadImage(merged, { preserveScroll: true });
|
|
2725
|
+
await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
|
|
2302
2726
|
const afterJson = this._serializeCanvasState();
|
|
2303
2727
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2304
2728
|
} catch (error) {
|
|
2305
2729
|
this._reportError('merge error', error);
|
|
2730
|
+
throw error;
|
|
2306
2731
|
}
|
|
2307
2732
|
}
|
|
2308
2733
|
|
|
@@ -2326,6 +2751,7 @@ function ensureFabric() {
|
|
|
2326
2751
|
*/
|
|
2327
2752
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
2328
2753
|
if (!this.originalImage) return;
|
|
2754
|
+
if (!this._canMutateNow('downloadImage')) return;
|
|
2329
2755
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
2330
2756
|
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
|
|
2331
2757
|
.then(imageBase64 => {
|
|
@@ -2357,6 +2783,7 @@ function ensureFabric() {
|
|
|
2357
2783
|
*/
|
|
2358
2784
|
async exportImageBase64(options = {}) {
|
|
2359
2785
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
2786
|
+
this._assertIdleForOperation('exportImageBase64');
|
|
2360
2787
|
const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
2361
2788
|
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
2362
2789
|
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
@@ -2374,7 +2801,7 @@ function ensureFabric() {
|
|
|
2374
2801
|
this.originalImage.setCoords();
|
|
2375
2802
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2376
2803
|
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2377
|
-
return
|
|
2804
|
+
return this._exportCanvasRegionToDataURL({
|
|
2378
2805
|
...exportRegion,
|
|
2379
2806
|
multiplier,
|
|
2380
2807
|
quality,
|
|
@@ -2420,7 +2847,7 @@ function ensureFabric() {
|
|
|
2420
2847
|
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2421
2848
|
|
|
2422
2849
|
// Crop precisely in offscreen canvas
|
|
2423
|
-
finalBase64 =
|
|
2850
|
+
finalBase64 = this._exportCanvasRegionToDataURL({
|
|
2424
2851
|
...exportRegion,
|
|
2425
2852
|
multiplier,
|
|
2426
2853
|
quality,
|
|
@@ -2478,6 +2905,7 @@ function ensureFabric() {
|
|
|
2478
2905
|
*/
|
|
2479
2906
|
async exportImageFile(options = {}) {
|
|
2480
2907
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
2908
|
+
this._assertIdleForOperation('exportImageFile');
|
|
2481
2909
|
const {
|
|
2482
2910
|
mergeMask = true,
|
|
2483
2911
|
fileType = 'jpeg',
|
|
@@ -2519,6 +2947,7 @@ function ensureFabric() {
|
|
|
2519
2947
|
offscreenCanvas.width = imageElement.width;
|
|
2520
2948
|
offscreenCanvas.height = imageElement.height;
|
|
2521
2949
|
const context = offscreenCanvas.getContext('2d');
|
|
2950
|
+
if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
|
|
2522
2951
|
context.drawImage(imageElement, 0, 0);
|
|
2523
2952
|
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
|
|
2524
2953
|
resolve(convertedDataUrl);
|
|
@@ -2591,13 +3020,15 @@ function ensureFabric() {
|
|
|
2591
3020
|
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2592
3021
|
this._cropHandlers.forEach(targetHandlers => {
|
|
2593
3022
|
targetHandlers.handlers.forEach(handlerRecord => {
|
|
2594
|
-
targetHandlers.target.off
|
|
3023
|
+
if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
|
|
3024
|
+
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
3025
|
+
}
|
|
2595
3026
|
});
|
|
2596
3027
|
});
|
|
2597
3028
|
}
|
|
2598
3029
|
} catch (error) { void error; }
|
|
2599
3030
|
|
|
2600
|
-
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
3031
|
+
try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
2601
3032
|
this._cropRect = null;
|
|
2602
3033
|
this._cropHandlers = [];
|
|
2603
3034
|
}
|
|
@@ -2613,7 +3044,9 @@ function ensureFabric() {
|
|
|
2613
3044
|
*/
|
|
2614
3045
|
enterCropMode() {
|
|
2615
3046
|
if (!this.canvas || !this.originalImage || this._cropMode) return;
|
|
3047
|
+
if (!this._canMutateNow('enterCropMode')) return;
|
|
2616
3048
|
if (!this.isImageLoaded()) return;
|
|
3049
|
+
this._removeCropRect();
|
|
2617
3050
|
this._cropMode = true;
|
|
2618
3051
|
|
|
2619
3052
|
// Disable group selection so only the crop rectangle can be manipulated.
|
|
@@ -2747,17 +3180,18 @@ function ensureFabric() {
|
|
|
2747
3180
|
*/
|
|
2748
3181
|
async applyCrop() {
|
|
2749
3182
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
3183
|
+
this._assertIdleForOperation('applyCrop');
|
|
2750
3184
|
|
|
2751
3185
|
// Fabric does not update control coordinates automatically after programmatic transforms.
|
|
2752
3186
|
this._cropRect.setCoords();
|
|
2753
3187
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
2754
3188
|
|
|
2755
|
-
const cropRegion = this._getClampedCanvasRegion(rectBounds);
|
|
3189
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
2756
3190
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2757
3191
|
|
|
2758
3192
|
this._restoreCropObjectState();
|
|
2759
3193
|
|
|
2760
|
-
let beforeJson
|
|
3194
|
+
let beforeJson;
|
|
2761
3195
|
try {
|
|
2762
3196
|
beforeJson = this._serializeCanvasState();
|
|
2763
3197
|
} catch (error) {
|
|
@@ -2782,12 +3216,8 @@ function ensureFabric() {
|
|
|
2782
3216
|
this._removeLabelForMask(mask);
|
|
2783
3217
|
this.canvas.remove(mask);
|
|
2784
3218
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2785
|
-
mask.
|
|
2786
|
-
|
|
2787
|
-
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2788
|
-
visible: true
|
|
2789
|
-
});
|
|
2790
|
-
mask.setCoords();
|
|
3219
|
+
this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
|
|
3220
|
+
mask.set({ visible: true });
|
|
2791
3221
|
preservedMasks.push(mask);
|
|
2792
3222
|
}
|
|
2793
3223
|
} catch (error) {
|
|
@@ -2825,7 +3255,7 @@ function ensureFabric() {
|
|
|
2825
3255
|
|
|
2826
3256
|
// Load the cropped image as the new base image.
|
|
2827
3257
|
try {
|
|
2828
|
-
await this.loadImage(croppedBase64);
|
|
3258
|
+
await this.loadImage(croppedBase64, { resetMaskCounter: false });
|
|
2829
3259
|
if (preservedMasks.length) {
|
|
2830
3260
|
preservedMasks.forEach(mask => {
|
|
2831
3261
|
this._rebindMaskEvents(mask);
|
|
@@ -2843,9 +3273,9 @@ function ensureFabric() {
|
|
|
2843
3273
|
}
|
|
2844
3274
|
|
|
2845
3275
|
// Create an after snapshot and push one history command for the crop operation.
|
|
2846
|
-
let afterJson
|
|
3276
|
+
let afterJson;
|
|
2847
3277
|
try {
|
|
2848
|
-
afterJson = this._serializeCanvasState();
|
|
3278
|
+
afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
|
|
2849
3279
|
} catch (error) {
|
|
2850
3280
|
this._reportWarning('applyCrop: failed to serialize after state', error);
|
|
2851
3281
|
afterJson = null;
|
|
@@ -2871,7 +3301,7 @@ function ensureFabric() {
|
|
|
2871
3301
|
* @private
|
|
2872
3302
|
*/
|
|
2873
3303
|
_updateInputs() {
|
|
2874
|
-
const scaleInputElement =
|
|
3304
|
+
const scaleInputElement = this._getElement('scaleRate');
|
|
2875
3305
|
if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
2876
3306
|
}
|
|
2877
3307
|
|
|
@@ -2881,6 +3311,7 @@ function ensureFabric() {
|
|
|
2881
3311
|
* @private
|
|
2882
3312
|
*/
|
|
2883
3313
|
_updateUI() {
|
|
3314
|
+
if (!this.canvas) return;
|
|
2884
3315
|
const hasImage = !!this.originalImage;
|
|
2885
3316
|
const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
|
|
2886
3317
|
const hasMasks = masks.length > 0;
|
|
@@ -2894,7 +3325,7 @@ function ensureFabric() {
|
|
|
2894
3325
|
if (isInCropMode) {
|
|
2895
3326
|
// Disable all controls except the crop action buttons while crop mode is active.
|
|
2896
3327
|
for (const key of Object.keys(this.elements || {})) {
|
|
2897
|
-
const element =
|
|
3328
|
+
const element = this._getElement(key);
|
|
2898
3329
|
if (!element) continue;
|
|
2899
3330
|
if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
|
|
2900
3331
|
this._setDisabled(key, false);
|
|
@@ -2932,7 +3363,7 @@ function ensureFabric() {
|
|
|
2932
3363
|
* @private
|
|
2933
3364
|
*/
|
|
2934
3365
|
_setDisabled(key, disabled) {
|
|
2935
|
-
const element =
|
|
3366
|
+
const element = this._getElement(key);
|
|
2936
3367
|
if (!element) return;
|
|
2937
3368
|
if ('disabled' in element) {
|
|
2938
3369
|
element.disabled = !!disabled;
|
|
@@ -2970,16 +3401,66 @@ function ensureFabric() {
|
|
|
2970
3401
|
* @private
|
|
2971
3402
|
*/
|
|
2972
3403
|
_setPlaceholderVisible(show) {
|
|
2973
|
-
if (
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
this.
|
|
2977
|
-
|
|
3404
|
+
if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
|
|
3405
|
+
const canvasVisibilityElement = this._getCanvasVisibilityElement();
|
|
3406
|
+
if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
|
|
3407
|
+
this._setElementVisible(canvasVisibilityElement, !show);
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
_getCanvasVisibilityElement() {
|
|
3412
|
+
const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
|
|
3413
|
+
if (
|
|
3414
|
+
this.containerElement &&
|
|
3415
|
+
this.placeholderElement &&
|
|
3416
|
+
(this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))
|
|
3417
|
+
) {
|
|
3418
|
+
return wrapperElement || this.canvasElement;
|
|
3419
|
+
}
|
|
3420
|
+
return this.containerElement || wrapperElement || this.canvasElement;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/**
|
|
3424
|
+
* Updates element visibility.
|
|
3425
|
+
*
|
|
3426
|
+
* @param {HTMLElement} element - Element whose visibility should be updated.
|
|
3427
|
+
* @param {boolean} isVisible - If true, removes the hidden state.
|
|
3428
|
+
* @returns {void}
|
|
3429
|
+
* @private
|
|
3430
|
+
*/
|
|
3431
|
+
_setElementVisible(element, isVisible) {
|
|
3432
|
+
if (!element) return;
|
|
3433
|
+
this._rememberElementVisibility(element);
|
|
3434
|
+
element.hidden = !isVisible;
|
|
3435
|
+
element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
|
|
3436
|
+
if (element.classList) {
|
|
3437
|
+
element.classList.toggle('d-none', !isVisible);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
_rememberElementVisibility(element) {
|
|
3442
|
+
if (!element || this._visibilityStateByElement.has(element)) return;
|
|
3443
|
+
this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
_captureElementVisibility(element) {
|
|
3447
|
+
if (!element) return null;
|
|
3448
|
+
return {
|
|
3449
|
+
hidden: element.hidden,
|
|
3450
|
+
ariaHidden: element.getAttribute('aria-hidden'),
|
|
3451
|
+
className: element.className
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
_restoreElementVisibility(element, state) {
|
|
3456
|
+
if (!element || !state) return;
|
|
3457
|
+
element.hidden = !!state.hidden;
|
|
3458
|
+
if (state.ariaHidden === null) {
|
|
3459
|
+
element.removeAttribute('aria-hidden');
|
|
2978
3460
|
} else {
|
|
2979
|
-
|
|
2980
|
-
this.placeholderElement.classList.add('d-none');
|
|
2981
|
-
this.containerElement.classList.remove('d-none');
|
|
3461
|
+
element.setAttribute('aria-hidden', state.ariaHidden);
|
|
2982
3462
|
}
|
|
3463
|
+
element.className = state.className || '';
|
|
2983
3464
|
}
|
|
2984
3465
|
|
|
2985
3466
|
/**
|
|
@@ -2988,11 +3469,16 @@ function ensureFabric() {
|
|
|
2988
3469
|
* @public
|
|
2989
3470
|
*/
|
|
2990
3471
|
dispose() {
|
|
3472
|
+
this._disposed = true;
|
|
3473
|
+
this._rejectActiveAnimations(new Error('Editor disposed during animation'));
|
|
3474
|
+
if (this.animationQueue) {
|
|
3475
|
+
this.animationQueue.cancelAll(new Error('Editor disposed'));
|
|
3476
|
+
}
|
|
3477
|
+
|
|
2991
3478
|
// Remove bound DOM event listeners
|
|
2992
3479
|
try {
|
|
2993
|
-
for (const key
|
|
2994
|
-
const
|
|
2995
|
-
const element = document.getElementById(this.elements[key]);
|
|
3480
|
+
for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
|
|
3481
|
+
const element = this._getElement(key);
|
|
2996
3482
|
if (!element) continue;
|
|
2997
3483
|
handlers.forEach(handlerRecord => {
|
|
2998
3484
|
try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
|
|
@@ -3005,8 +3491,25 @@ function ensureFabric() {
|
|
|
3005
3491
|
this._cropRect = null;
|
|
3006
3492
|
}
|
|
3007
3493
|
|
|
3008
|
-
if (this.containerElement && this._containerOriginalOverflow
|
|
3009
|
-
try { this.
|
|
3494
|
+
if (this.containerElement && this._containerOriginalOverflow) {
|
|
3495
|
+
try { this._restoreContainerOverflowState(); } catch (error) { void error; }
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
if (this._visibilityStateByElement) {
|
|
3499
|
+
try {
|
|
3500
|
+
[this.placeholderElement, this._getCanvasVisibilityElement()].forEach(element => {
|
|
3501
|
+
const state = element ? this._visibilityStateByElement.get(element) : null;
|
|
3502
|
+
if (state) this._restoreElementVisibility(element, state);
|
|
3503
|
+
});
|
|
3504
|
+
} catch (error) { void error; }
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
if (this.canvasElement && this._canvasElementOriginalStyle) {
|
|
3508
|
+
try {
|
|
3509
|
+
this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
|
|
3510
|
+
this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
|
|
3511
|
+
this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
|
|
3512
|
+
} catch (error) { void error; }
|
|
3010
3513
|
}
|
|
3011
3514
|
|
|
3012
3515
|
if (this.canvas) {
|
|
@@ -3016,6 +3519,19 @@ function ensureFabric() {
|
|
|
3016
3519
|
this.isImageLoadedToCanvas = false;
|
|
3017
3520
|
}
|
|
3018
3521
|
this._handlersByElementKey = {};
|
|
3522
|
+
this._elementCache = {};
|
|
3523
|
+
this._clearMaskPlacementMemory();
|
|
3524
|
+
this.originalImage = null;
|
|
3525
|
+
this.baseImageScale = 1;
|
|
3526
|
+
this.currentScale = 1;
|
|
3527
|
+
this.currentRotation = 0;
|
|
3528
|
+
this.isAnimating = false;
|
|
3529
|
+
this._cropMode = false;
|
|
3530
|
+
this._cropRect = null;
|
|
3531
|
+
this._cropHandlers = [];
|
|
3532
|
+
this._cropPrevEvented = null;
|
|
3533
|
+
this._prevSelectionSetting = undefined;
|
|
3534
|
+
this._initialized = false;
|
|
3019
3535
|
}
|
|
3020
3536
|
}
|
|
3021
3537
|
|
|
@@ -3068,6 +3584,7 @@ function ensureFabric() {
|
|
|
3068
3584
|
* @type {boolean}
|
|
3069
3585
|
*/
|
|
3070
3586
|
this.isRunning = false;
|
|
3587
|
+
this.currentTask = null;
|
|
3071
3588
|
}
|
|
3072
3589
|
|
|
3073
3590
|
/**
|
|
@@ -3078,13 +3595,32 @@ function ensureFabric() {
|
|
|
3078
3595
|
*/
|
|
3079
3596
|
async add(animationFn) {
|
|
3080
3597
|
return new Promise((resolve, reject) => {
|
|
3081
|
-
this.animationTasks.push({ animationFn, resolve, reject });
|
|
3598
|
+
this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
|
|
3082
3599
|
if (!this.isRunning) {
|
|
3083
3600
|
this._drainQueue();
|
|
3084
3601
|
}
|
|
3085
3602
|
});
|
|
3086
3603
|
}
|
|
3087
3604
|
|
|
3605
|
+
isBusy() {
|
|
3606
|
+
return this.isRunning || this.animationTasks.length > 0;
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
cancelAll(reason = new Error('Animation queue cancelled')) {
|
|
3610
|
+
const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
|
|
3611
|
+
const tasks = [
|
|
3612
|
+
...(this.currentTask ? [this.currentTask] : []),
|
|
3613
|
+
...this.animationTasks.splice(0)
|
|
3614
|
+
];
|
|
3615
|
+
tasks.forEach(task => {
|
|
3616
|
+
if (!task || task.isSettled) return;
|
|
3617
|
+
task.isSettled = true;
|
|
3618
|
+
task.reject(cancellationError);
|
|
3619
|
+
});
|
|
3620
|
+
this.isRunning = false;
|
|
3621
|
+
this.currentTask = null;
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3088
3624
|
/**
|
|
3089
3625
|
* Runs queued animation tasks sequentially until the queue is empty.
|
|
3090
3626
|
*
|
|
@@ -3092,22 +3628,30 @@ function ensureFabric() {
|
|
|
3092
3628
|
* @returns {Promise<void>}
|
|
3093
3629
|
*/
|
|
3094
3630
|
async _drainQueue() {
|
|
3095
|
-
if (this.
|
|
3096
|
-
this.isRunning = false;
|
|
3097
|
-
return;
|
|
3098
|
-
}
|
|
3099
|
-
|
|
3631
|
+
if (this.isRunning) return;
|
|
3100
3632
|
this.isRunning = true;
|
|
3101
|
-
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
3102
3633
|
|
|
3103
|
-
|
|
3104
|
-
const
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3634
|
+
while (this.animationTasks.length > 0) {
|
|
3635
|
+
const task = this.animationTasks.shift();
|
|
3636
|
+
this.currentTask = task;
|
|
3637
|
+
|
|
3638
|
+
try {
|
|
3639
|
+
const result = await task.animationFn();
|
|
3640
|
+
if (!task.isSettled) {
|
|
3641
|
+
task.isSettled = true;
|
|
3642
|
+
task.resolve(result);
|
|
3643
|
+
}
|
|
3644
|
+
} catch (error) {
|
|
3645
|
+
if (!task.isSettled) {
|
|
3646
|
+
task.isSettled = true;
|
|
3647
|
+
task.reject(error);
|
|
3648
|
+
}
|
|
3649
|
+
} finally {
|
|
3650
|
+
if (this.currentTask === task) this.currentTask = null;
|
|
3651
|
+
}
|
|
3108
3652
|
}
|
|
3109
3653
|
|
|
3110
|
-
|
|
3654
|
+
this.isRunning = false;
|
|
3111
3655
|
}
|
|
3112
3656
|
}
|
|
3113
3657
|
|
|
@@ -3163,16 +3707,8 @@ function ensureFabric() {
|
|
|
3163
3707
|
* @private
|
|
3164
3708
|
*/
|
|
3165
3709
|
enqueue(task) {
|
|
3166
|
-
const nextTask = this.pending.then(
|
|
3167
|
-
|
|
3168
|
-
const resetPending = () => {
|
|
3169
|
-
if (this.pending === pendingAfterTask) {
|
|
3170
|
-
this.pending = Promise.resolve();
|
|
3171
|
-
}
|
|
3172
|
-
};
|
|
3173
|
-
|
|
3174
|
-
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
3175
|
-
this.pending = pendingAfterTask;
|
|
3710
|
+
const nextTask = this.pending.then(() => Promise.resolve().then(task));
|
|
3711
|
+
this.pending = nextTask.catch(() => undefined);
|
|
3176
3712
|
return nextTask;
|
|
3177
3713
|
}
|
|
3178
3714
|
|
|
@@ -3184,8 +3720,14 @@ function ensureFabric() {
|
|
|
3184
3720
|
* @returns {void}
|
|
3185
3721
|
*/
|
|
3186
3722
|
execute(command) {
|
|
3187
|
-
command.execute();
|
|
3723
|
+
const result = command.execute();
|
|
3724
|
+
if (result && typeof result.then === 'function') {
|
|
3725
|
+
return Promise.resolve(result).then(() => {
|
|
3726
|
+
this.push(command);
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3188
3729
|
this.push(command);
|
|
3730
|
+
return result;
|
|
3189
3731
|
}
|
|
3190
3732
|
|
|
3191
3733
|
/**
|
|
@@ -3205,9 +3747,8 @@ function ensureFabric() {
|
|
|
3205
3747
|
|
|
3206
3748
|
if (this.history.length > this.maxSize) {
|
|
3207
3749
|
this.history.shift();
|
|
3208
|
-
} else {
|
|
3209
|
-
this.currentIndex++;
|
|
3210
3750
|
}
|
|
3751
|
+
this.currentIndex = this.history.length - 1;
|
|
3211
3752
|
}
|
|
3212
3753
|
|
|
3213
3754
|
/**
|