@bensitu/image-editor 1.4.2 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +479 -130
- package/dist/image-editor.cjs +4185 -0
- package/dist/image-editor.cjs.map +7 -0
- package/dist/image-editor.esm.js +868 -377
- package/dist/image-editor.esm.js.map +3 -3
- package/dist/image-editor.esm.min.js +3 -3
- package/dist/image-editor.esm.min.js.map +3 -3
- package/dist/image-editor.esm.min.mjs +3 -3
- package/dist/image-editor.esm.min.mjs.map +3 -3
- package/dist/image-editor.esm.mjs +868 -377
- package/dist/image-editor.esm.mjs.map +3 -3
- package/dist/image-editor.js +868 -377
- package/dist/image-editor.js.map +3 -3
- package/dist/image-editor.min.js +2 -2
- package/dist/image-editor.min.js.map +3 -3
- package/image-editor.d.ts +64 -22
- package/package.json +4 -3
- package/src/image-editor.js +818 -267
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.5.1
|
|
5
5
|
* @author Ben Situ
|
|
6
6
|
* @license MIT
|
|
7
7
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
@@ -132,6 +132,7 @@ function ensureFabric() {
|
|
|
132
132
|
* @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
|
|
133
133
|
* @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
|
|
134
134
|
* @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
|
|
135
|
+
* @param {number} [options.maxExportPixels=50000000] - Maximum output pixels allowed per export.
|
|
135
136
|
* @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
|
|
136
137
|
* @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
|
|
137
138
|
* @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
|
|
@@ -202,6 +203,7 @@ function ensureFabric() {
|
|
|
202
203
|
imageLoadTimeoutMs: 30000,
|
|
203
204
|
|
|
204
205
|
exportMultiplier: 1,
|
|
206
|
+
maxExportPixels: 50000000,
|
|
205
207
|
exportImageAreaByDefault: true,
|
|
206
208
|
|
|
207
209
|
defaultMaskWidth: 50,
|
|
@@ -283,8 +285,10 @@ function ensureFabric() {
|
|
|
283
285
|
this._activeAnimationRejectors = new Set();
|
|
284
286
|
this._disposed = false;
|
|
285
287
|
this._initialized = false;
|
|
288
|
+
this._deprecatedElementKeyWarnings = new Set();
|
|
289
|
+
this._cropRotationWarningEmitted = false;
|
|
286
290
|
|
|
287
|
-
this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
|
|
291
|
+
this.onImageLoaded = typeof this.options.onImageLoaded === 'function' ? this.options.onImageLoaded : null;
|
|
288
292
|
|
|
289
293
|
this.animationQueue = new AnimationQueue();
|
|
290
294
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
@@ -338,10 +342,12 @@ function ensureFabric() {
|
|
|
338
342
|
* Use this method to set up the editor UI before interacting with it.
|
|
339
343
|
*
|
|
340
344
|
* @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
|
|
341
|
-
* Supported keys include: canvas, canvasContainer,
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
+
* Supported keys include: canvas, canvasContainer, imagePlaceholder, scalePercentageInput,
|
|
346
|
+
* rotateLeftDegreesInput, rotateRightDegreesInput, rotateLeftButton, rotateRightButton,
|
|
347
|
+
* createMaskButton, removeSelectedMaskButton, removeAllMasksButton, mergeMasksButton,
|
|
348
|
+
* downloadImageButton, maskList, zoomInButton, zoomOutButton, resetImageTransformButton,
|
|
349
|
+
* undoButton, redoButton, imageInput, uploadArea, enterCropModeButton, applyCropButton,
|
|
350
|
+
* and cancelCropButton. Deprecated 1.x names remain supported as aliases.
|
|
345
351
|
*
|
|
346
352
|
* @returns {void}
|
|
347
353
|
*
|
|
@@ -350,11 +356,17 @@ function ensureFabric() {
|
|
|
350
356
|
* @example
|
|
351
357
|
* editor.init({
|
|
352
358
|
* canvas: 'myFabricCanvasId',
|
|
353
|
-
*
|
|
359
|
+
* downloadImageButton: 'myDownloadButtonId'
|
|
354
360
|
* });
|
|
355
361
|
*/
|
|
356
362
|
init(idMap = {}) {
|
|
357
|
-
if (!this._fabricLoaded)
|
|
363
|
+
if (!this._fabricLoaded) {
|
|
364
|
+
this._fabricLoaded = !!ensureFabric();
|
|
365
|
+
if (!this._fabricLoaded) {
|
|
366
|
+
this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
358
370
|
if (this._initialized || this.canvas) this.dispose();
|
|
359
371
|
this._disposed = false;
|
|
360
372
|
this._initialized = true;
|
|
@@ -373,30 +385,50 @@ function ensureFabric() {
|
|
|
373
385
|
const defaults = {
|
|
374
386
|
canvas: 'fabricCanvas',
|
|
375
387
|
canvasContainer: null, // Pass an ID here if you have a scrollable viewport container
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
388
|
+
imagePlaceholder: 'imagePlaceholder',
|
|
389
|
+
imgPlaceholder: null,
|
|
390
|
+
scalePercentageInput: 'scalePercentageInput',
|
|
391
|
+
scaleRate: null,
|
|
392
|
+
rotateLeftDegreesInput: 'rotateLeftDegreesInput',
|
|
393
|
+
rotationLeftInput: null,
|
|
394
|
+
rotateRightDegreesInput: 'rotateRightDegreesInput',
|
|
395
|
+
rotationRightInput: null,
|
|
396
|
+
rotateLeftButton: 'rotateLeftButton',
|
|
397
|
+
rotateLeftBtn: null,
|
|
398
|
+
rotateRightButton: 'rotateRightButton',
|
|
399
|
+
rotateRightBtn: null,
|
|
400
|
+
createMaskButton: 'createMaskButton',
|
|
401
|
+
addMaskBtn: null,
|
|
402
|
+
removeSelectedMaskButton: 'removeSelectedMaskButton',
|
|
403
|
+
removeMaskBtn: null,
|
|
404
|
+
removeAllMasksButton: 'removeAllMasksButton',
|
|
405
|
+
removeAllMasksBtn: null,
|
|
406
|
+
mergeMasksButton: 'mergeMasksButton',
|
|
407
|
+
mergeBtn: null,
|
|
408
|
+
downloadImageButton: 'downloadImageButton',
|
|
409
|
+
downloadBtn: null,
|
|
387
410
|
maskList: 'maskList',
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
411
|
+
zoomInButton: 'zoomInButton',
|
|
412
|
+
zoomInBtn: null,
|
|
413
|
+
zoomOutButton: 'zoomOutButton',
|
|
414
|
+
zoomOutBtn: null,
|
|
415
|
+
resetImageTransformButton: 'resetImageTransformButton',
|
|
416
|
+
resetBtn: null,
|
|
417
|
+
undoButton: 'undoButton',
|
|
418
|
+
undoBtn: null,
|
|
419
|
+
redoButton: 'redoButton',
|
|
420
|
+
redoBtn: null,
|
|
393
421
|
imageInput: 'imageInput',
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
422
|
+
uploadArea: null,
|
|
423
|
+
enterCropModeButton: 'enterCropModeButton',
|
|
424
|
+
cropBtn: null,
|
|
425
|
+
applyCropButton: 'applyCropButton',
|
|
426
|
+
applyCropBtn: null,
|
|
427
|
+
cancelCropButton: 'cancelCropButton',
|
|
428
|
+
cancelCropBtn: null
|
|
397
429
|
};
|
|
398
430
|
|
|
399
|
-
this.elements = {
|
|
431
|
+
this.elements = this._resolveElementIdMap(idMap || {}, defaults);
|
|
400
432
|
this._elementCache = {};
|
|
401
433
|
|
|
402
434
|
this._initCanvas();
|
|
@@ -407,12 +439,80 @@ function ensureFabric() {
|
|
|
407
439
|
|
|
408
440
|
// Auto-load initial image if provided
|
|
409
441
|
if (this.options.initialImageBase64) {
|
|
410
|
-
this.loadImage(this.options.initialImageBase64)
|
|
442
|
+
this.loadImage(this.options.initialImageBase64)
|
|
443
|
+
.catch(error => this._reportError('initialImageBase64 could not be loaded', error));
|
|
411
444
|
} else {
|
|
412
445
|
this._updatePlaceholderStatus();
|
|
413
446
|
}
|
|
414
447
|
}
|
|
415
448
|
|
|
449
|
+
_resolveElementIdMap(idMap, defaults) {
|
|
450
|
+
const resolved = { ...defaults, ...idMap };
|
|
451
|
+
|
|
452
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'imagePlaceholder', ['imgPlaceholder']);
|
|
453
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'scalePercentageInput', ['scaleRate']);
|
|
454
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'rotateLeftDegreesInput', ['rotationLeftInput']);
|
|
455
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'rotateRightDegreesInput', ['rotationRightInput']);
|
|
456
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'rotateLeftButton', 'rotateLeftBtn');
|
|
457
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'rotateRightButton', 'rotateRightBtn');
|
|
458
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'createMaskButton', 'addMaskBtn');
|
|
459
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'removeSelectedMaskButton', ['removeMaskBtn']);
|
|
460
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'removeAllMasksButton', 'removeAllMasksBtn');
|
|
461
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'mergeMasksButton', 'mergeBtn');
|
|
462
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'downloadImageButton', ['downloadBtn']);
|
|
463
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'zoomInButton', 'zoomInBtn');
|
|
464
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'zoomOutButton', 'zoomOutBtn');
|
|
465
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'resetImageTransformButton', 'resetBtn');
|
|
466
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'undoButton', 'undoBtn');
|
|
467
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'redoButton', 'redoBtn');
|
|
468
|
+
this._resolveElementAliases(resolved, idMap, defaults, 'enterCropModeButton', ['cropBtn']);
|
|
469
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'applyCropButton', 'applyCropBtn');
|
|
470
|
+
this._resolveElementAlias(resolved, idMap, defaults, 'cancelCropButton', 'cancelCropBtn');
|
|
471
|
+
|
|
472
|
+
return resolved;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
_resolveElementAlias(resolved, idMap, defaults, canonicalKey, deprecatedKey) {
|
|
476
|
+
this._resolveElementAliases(resolved, idMap, defaults, canonicalKey, [deprecatedKey]);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_resolveElementAliases(resolved, idMap, defaults, canonicalKey, deprecatedKeys) {
|
|
480
|
+
const hasCanonicalKey = Object.prototype.hasOwnProperty.call(idMap, canonicalKey);
|
|
481
|
+
|
|
482
|
+
if (hasCanonicalKey) {
|
|
483
|
+
resolved[canonicalKey] = idMap[canonicalKey];
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let deprecatedValue;
|
|
488
|
+
let hasDeprecatedValue = false;
|
|
489
|
+
for (const deprecatedKey of deprecatedKeys) {
|
|
490
|
+
if (Object.prototype.hasOwnProperty.call(idMap, deprecatedKey)) {
|
|
491
|
+
if (!hasDeprecatedValue) {
|
|
492
|
+
deprecatedValue = idMap[deprecatedKey];
|
|
493
|
+
hasDeprecatedValue = true;
|
|
494
|
+
}
|
|
495
|
+
this._warnDeprecatedElementIdKey(deprecatedKey, canonicalKey);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (hasDeprecatedValue) {
|
|
500
|
+
resolved[canonicalKey] = deprecatedValue;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
resolved[canonicalKey] = defaults[canonicalKey];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
_warnDeprecatedElementIdKey(deprecatedKey, canonicalKey) {
|
|
508
|
+
if (!this._deprecatedElementKeyWarnings) this._deprecatedElementKeyWarnings = new Set();
|
|
509
|
+
if (this._deprecatedElementKeyWarnings.has(deprecatedKey)) return;
|
|
510
|
+
this._deprecatedElementKeyWarnings.add(deprecatedKey);
|
|
511
|
+
this._reportWarning(
|
|
512
|
+
`ElementIdMap.${deprecatedKey} is deprecated. Use ${canonicalKey} instead. This alias will be removed in v2.0.0.`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
416
516
|
_reportError(message, error = null) {
|
|
417
517
|
const handler = this.options && this.options.onError;
|
|
418
518
|
if (typeof handler !== 'function') return;
|
|
@@ -435,6 +535,12 @@ function ensureFabric() {
|
|
|
435
535
|
}
|
|
436
536
|
}
|
|
437
537
|
|
|
538
|
+
_notifyImageLoaded() {
|
|
539
|
+
const optionsCallback = this.options && this.options.onImageLoaded;
|
|
540
|
+
const callback = typeof optionsCallback === 'function' ? optionsCallback : this.onImageLoaded;
|
|
541
|
+
if (typeof callback === 'function') callback();
|
|
542
|
+
}
|
|
543
|
+
|
|
438
544
|
/**
|
|
439
545
|
* Initializes the Fabric canvas, viewport elements, and selection event handlers.
|
|
440
546
|
*
|
|
@@ -460,7 +566,7 @@ function ensureFabric() {
|
|
|
460
566
|
this.containerElement = canvasElement.parentElement;
|
|
461
567
|
}
|
|
462
568
|
|
|
463
|
-
this.placeholderElement = this._getElement('
|
|
569
|
+
this.placeholderElement = this._getElement('imagePlaceholder') || null;
|
|
464
570
|
|
|
465
571
|
// Prefer a measured container size when it is available.
|
|
466
572
|
let initialWidth = this.options.canvasWidth;
|
|
@@ -565,13 +671,14 @@ function ensureFabric() {
|
|
|
565
671
|
this._captureContainerOverflowState();
|
|
566
672
|
|
|
567
673
|
const shouldPreserveScroll = options.preserveScroll === true;
|
|
568
|
-
|
|
674
|
+
const layoutMode = this._getImageLayoutMode();
|
|
675
|
+
if (layoutMode === 'cover') {
|
|
569
676
|
this.containerElement.style.overflow = 'scroll';
|
|
570
677
|
if (!shouldPreserveScroll) {
|
|
571
678
|
this.containerElement.scrollLeft = 0;
|
|
572
679
|
this.containerElement.scrollTop = 0;
|
|
573
680
|
}
|
|
574
|
-
} else if (
|
|
681
|
+
} else if (layoutMode === 'fit') {
|
|
575
682
|
this.containerElement.style.overflow = 'auto';
|
|
576
683
|
if (!shouldPreserveScroll) {
|
|
577
684
|
this.containerElement.scrollLeft = 0;
|
|
@@ -628,23 +735,23 @@ function ensureFabric() {
|
|
|
628
735
|
}
|
|
629
736
|
});
|
|
630
737
|
// Zoom & reset
|
|
631
|
-
this._bindIfExists('
|
|
632
|
-
this._bindIfExists('
|
|
633
|
-
this._bindIfExists('
|
|
738
|
+
this._bindIfExists('zoomInButton', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
739
|
+
this._bindIfExists('zoomOutButton', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep).catch(error => this._reportError('scaleImage failed', error)));
|
|
740
|
+
this._bindIfExists('resetImageTransformButton', 'click', () => { this.resetImageTransform().catch(error => this._reportError('resetImageTransform failed', error)); });
|
|
634
741
|
// Mask management
|
|
635
|
-
this._bindIfExists('
|
|
636
|
-
this._bindIfExists('
|
|
637
|
-
this._bindIfExists('
|
|
742
|
+
this._bindIfExists('createMaskButton', 'click', () => this.createMask());
|
|
743
|
+
this._bindIfExists('removeSelectedMaskButton', 'click', () => this.removeSelectedMask());
|
|
744
|
+
this._bindIfExists('removeAllMasksButton', 'click', () => this.removeAllMasks());
|
|
638
745
|
// Merge + download
|
|
639
|
-
this._bindIfExists('
|
|
640
|
-
this._bindIfExists('
|
|
746
|
+
this._bindIfExists('mergeMasksButton', 'click', () => this.mergeMasks().catch(error => this._reportError('merge error', error)));
|
|
747
|
+
this._bindIfExists('downloadImageButton', 'click', () => this.downloadImage());
|
|
641
748
|
// Undo + Redo
|
|
642
|
-
this._bindIfExists('
|
|
643
|
-
this._bindIfExists('
|
|
749
|
+
this._bindIfExists('undoButton', 'click', () => this.undo().catch(error => this._reportError('undo failed', error)));
|
|
750
|
+
this._bindIfExists('redoButton', 'click', () => this.redo().catch(error => this._reportError('redo failed', error)));
|
|
644
751
|
|
|
645
752
|
// Rotation buttons (step can be overridden by two input fields)
|
|
646
|
-
this._bindIfExists('
|
|
647
|
-
const rotationInputElement = this._getElement('
|
|
753
|
+
this._bindIfExists('rotateLeftButton', 'click', () => {
|
|
754
|
+
const rotationInputElement = this._getElement('rotateLeftDegreesInput');
|
|
648
755
|
let step = this.options.rotationStep;
|
|
649
756
|
if (rotationInputElement) {
|
|
650
757
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
@@ -652,8 +759,8 @@ function ensureFabric() {
|
|
|
652
759
|
}
|
|
653
760
|
this.rotateImage(this.currentRotation - step).catch(error => this._reportError('rotateImage failed', error));
|
|
654
761
|
});
|
|
655
|
-
this._bindIfExists('
|
|
656
|
-
const rotationInputElement = this._getElement('
|
|
762
|
+
this._bindIfExists('rotateRightButton', 'click', () => {
|
|
763
|
+
const rotationInputElement = this._getElement('rotateRightDegreesInput');
|
|
657
764
|
let step = this.options.rotationStep;
|
|
658
765
|
if (rotationInputElement) {
|
|
659
766
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
@@ -663,9 +770,9 @@ function ensureFabric() {
|
|
|
663
770
|
});
|
|
664
771
|
|
|
665
772
|
// Crop bindings (optional: bound only if element IDs exist in elements)
|
|
666
|
-
this._bindIfExists('
|
|
667
|
-
this._bindIfExists('
|
|
668
|
-
this._bindIfExists('
|
|
773
|
+
this._bindIfExists('enterCropModeButton', 'click', () => this.enterCropMode());
|
|
774
|
+
this._bindIfExists('applyCropButton', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
|
|
775
|
+
this._bindIfExists('cancelCropButton', 'click', () => this.cancelCrop());
|
|
669
776
|
this._bindIfExists('maskList', 'click', (event) => this._handleMaskListClick(event));
|
|
670
777
|
}
|
|
671
778
|
|
|
@@ -743,6 +850,13 @@ function ensureFabric() {
|
|
|
743
850
|
);
|
|
744
851
|
}
|
|
745
852
|
|
|
853
|
+
_getImageLayoutMode() {
|
|
854
|
+
if (this.options.fitImageToCanvas) return 'fit';
|
|
855
|
+
if (this.options.coverImageToCanvas) return 'cover';
|
|
856
|
+
if (this.options.expandCanvasToImage) return 'expand';
|
|
857
|
+
return 'contain';
|
|
858
|
+
}
|
|
859
|
+
|
|
746
860
|
/**
|
|
747
861
|
* Loads a base64 data URL into the Fabric canvas as the base image.
|
|
748
862
|
*
|
|
@@ -756,14 +870,21 @@ function ensureFabric() {
|
|
|
756
870
|
if (!this._fabricLoaded) return;
|
|
757
871
|
if (!this.canvas || this._disposed) return;
|
|
758
872
|
if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
|
|
873
|
+
options = options || {};
|
|
759
874
|
this._assertIdleForOperation('loadImage', options);
|
|
760
875
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
876
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
877
|
+
const operationToken = isNestedOperation
|
|
878
|
+
? this._getInternalOperationToken(options)
|
|
879
|
+
: this._beginBusyOperation('loadImage');
|
|
880
|
+
let transaction = null;
|
|
765
881
|
|
|
766
882
|
try {
|
|
883
|
+
this._isLoading = true;
|
|
884
|
+
this._updateUI();
|
|
885
|
+
this._warnOnImageLayoutOptionConflict();
|
|
886
|
+
transaction = this._captureLoadImageTransaction();
|
|
887
|
+
|
|
767
888
|
const imageElement = await this._createImageElement(imageBase64);
|
|
768
889
|
if (this._disposed || !this.canvas) throw new Error('Editor was disposed while loading image');
|
|
769
890
|
|
|
@@ -811,8 +932,9 @@ function ensureFabric() {
|
|
|
811
932
|
const viewport = this._getContainerViewportSize();
|
|
812
933
|
const minWidth = viewport.width;
|
|
813
934
|
const minHeight = viewport.height;
|
|
935
|
+
const layoutMode = this._getImageLayoutMode();
|
|
814
936
|
|
|
815
|
-
if (
|
|
937
|
+
if (layoutMode === 'fit') {
|
|
816
938
|
const canvasWidth = Math.max(1, minWidth - 1);
|
|
817
939
|
const canvasHeight = Math.max(1, minHeight - 1);
|
|
818
940
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
@@ -820,13 +942,13 @@ function ensureFabric() {
|
|
|
820
942
|
fabricImage.set({ left: 0, top: 0 });
|
|
821
943
|
fabricImage.scale(fitScale);
|
|
822
944
|
this.baseImageScale = fabricImage.scaleX || 1;
|
|
823
|
-
} else if (
|
|
945
|
+
} else if (layoutMode === 'cover') {
|
|
824
946
|
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
825
947
|
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
826
948
|
fabricImage.set({ left: 0, top: 0 });
|
|
827
949
|
fabricImage.scale(layout.scale);
|
|
828
950
|
this.baseImageScale = fabricImage.scaleX || 1;
|
|
829
|
-
} else if (
|
|
951
|
+
} else if (layoutMode === 'expand') {
|
|
830
952
|
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
831
953
|
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
832
954
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
@@ -860,14 +982,16 @@ function ensureFabric() {
|
|
|
860
982
|
this.canvas.renderAll();
|
|
861
983
|
this._lastSnapshot = this._captureCanvasStateOrThrow('loadImage');
|
|
862
984
|
|
|
863
|
-
|
|
864
|
-
this.onImageLoaded();
|
|
865
|
-
}
|
|
985
|
+
this._notifyImageLoaded();
|
|
866
986
|
} catch (error) {
|
|
867
|
-
await this._rollbackLoadImageTransaction(
|
|
987
|
+
await this._rollbackLoadImageTransaction(
|
|
988
|
+
transaction,
|
|
989
|
+
this._withInternalOperationOptions(operationToken)
|
|
990
|
+
);
|
|
868
991
|
throw error;
|
|
869
992
|
} finally {
|
|
870
993
|
this._isLoading = false;
|
|
994
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
871
995
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
872
996
|
}
|
|
873
997
|
}
|
|
@@ -929,7 +1053,7 @@ function ensureFabric() {
|
|
|
929
1053
|
};
|
|
930
1054
|
timerId = setTimeout(() => {
|
|
931
1055
|
settle(() => reject(new Error('Image load timed out')));
|
|
932
|
-
try { imageElement.src = ''; } catch (error) {
|
|
1056
|
+
try { imageElement.src = ''; } catch (error) { this._reportWarning('Image timeout cleanup failed', error); }
|
|
933
1057
|
}, safeTimeoutMs);
|
|
934
1058
|
imageElement.onload = () => settle(() => resolve(imageElement));
|
|
935
1059
|
imageElement.onerror = (error) => settle(() => reject(error));
|
|
@@ -999,33 +1123,39 @@ function ensureFabric() {
|
|
|
999
1123
|
};
|
|
1000
1124
|
}
|
|
1001
1125
|
|
|
1002
|
-
async _rollbackLoadImageTransaction(transaction) {
|
|
1126
|
+
async _rollbackLoadImageTransaction(transaction, options = {}) {
|
|
1003
1127
|
if (!transaction || !this.canvas || this._disposed) return;
|
|
1004
1128
|
let didRestoreCanvasState = false;
|
|
1129
|
+
let didFailCanvasRestore = false;
|
|
1005
1130
|
try {
|
|
1006
1131
|
if (transaction.canvasState) {
|
|
1007
|
-
await this.loadFromState(transaction.canvasState);
|
|
1132
|
+
await this.loadFromState(transaction.canvasState, options);
|
|
1008
1133
|
didRestoreCanvasState = true;
|
|
1009
1134
|
}
|
|
1010
1135
|
} catch (error) {
|
|
1011
1136
|
this._lastMask = null;
|
|
1137
|
+
didFailCanvasRestore = true;
|
|
1012
1138
|
this._reportError('loadImage rollback failed', error);
|
|
1013
1139
|
}
|
|
1014
1140
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
this.currentRotation = transaction.currentRotation;
|
|
1018
|
-
this.maskCounter = transaction.maskCounter;
|
|
1019
|
-
this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
|
|
1020
|
-
this._lastSnapshot = transaction.lastSnapshot;
|
|
1021
|
-
if (didRestoreCanvasState) {
|
|
1022
|
-
this._restoreLastMaskReference(transaction.lastMask);
|
|
1141
|
+
if (didFailCanvasRestore) {
|
|
1142
|
+
this._reconcileEditorStateFromCanvas();
|
|
1023
1143
|
} else {
|
|
1024
|
-
this.
|
|
1144
|
+
this.baseImageScale = transaction.baseImageScale;
|
|
1145
|
+
this.currentScale = transaction.currentScale;
|
|
1146
|
+
this.currentRotation = transaction.currentRotation;
|
|
1147
|
+
this.maskCounter = transaction.maskCounter;
|
|
1148
|
+
this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
|
|
1149
|
+
this._lastSnapshot = transaction.lastSnapshot;
|
|
1150
|
+
if (didRestoreCanvasState) {
|
|
1151
|
+
this._restoreLastMaskReference(transaction.lastMask);
|
|
1152
|
+
} else {
|
|
1153
|
+
this._lastMask = null;
|
|
1154
|
+
}
|
|
1155
|
+
this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
|
|
1156
|
+
this._lastMaskInitialTop = transaction.lastMaskInitialTop;
|
|
1157
|
+
this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
|
|
1025
1158
|
}
|
|
1026
|
-
this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
|
|
1027
|
-
this._lastMaskInitialTop = transaction.lastMaskInitialTop;
|
|
1028
|
-
this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
|
|
1029
1159
|
this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
|
|
1030
1160
|
this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
|
|
1031
1161
|
if (this.containerElement) {
|
|
@@ -1039,6 +1169,49 @@ function ensureFabric() {
|
|
|
1039
1169
|
if (this.canvas) this.canvas.renderAll();
|
|
1040
1170
|
}
|
|
1041
1171
|
|
|
1172
|
+
_reconcileEditorStateFromCanvas() {
|
|
1173
|
+
if (!this.canvas) {
|
|
1174
|
+
this.originalImage = null;
|
|
1175
|
+
this.baseImageScale = 1;
|
|
1176
|
+
this.currentScale = 1;
|
|
1177
|
+
this.currentRotation = 0;
|
|
1178
|
+
this.maskCounter = 0;
|
|
1179
|
+
this.isImageLoadedToCanvas = false;
|
|
1180
|
+
this._lastSnapshot = null;
|
|
1181
|
+
this._clearMaskPlacementMemory();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1186
|
+
this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
|
|
1187
|
+
if (this.originalImage) {
|
|
1188
|
+
const imageScale = Number(this.originalImage.scaleX) || 1;
|
|
1189
|
+
this.baseImageScale = imageScale;
|
|
1190
|
+
this.currentScale = 1;
|
|
1191
|
+
this.currentRotation = Number(this.originalImage.angle) || 0;
|
|
1192
|
+
} else {
|
|
1193
|
+
this.baseImageScale = 1;
|
|
1194
|
+
this.currentScale = 1;
|
|
1195
|
+
this.currentRotation = 0;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const masks = canvasObjects.filter(object => object.maskId);
|
|
1199
|
+
this.maskCounter = masks.reduce((max, mask) => Math.max(max, Number(mask.maskId) || 0), 0);
|
|
1200
|
+
this._lastMask = masks[masks.length - 1] || null;
|
|
1201
|
+
if (!this._lastMask) {
|
|
1202
|
+
this._lastMaskInitialLeft = null;
|
|
1203
|
+
this._lastMaskInitialTop = null;
|
|
1204
|
+
this._lastMaskInitialWidth = null;
|
|
1205
|
+
}
|
|
1206
|
+
this.isImageLoadedToCanvas = !!this.originalImage;
|
|
1207
|
+
try {
|
|
1208
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
this._lastSnapshot = null;
|
|
1211
|
+
this._reportWarning('loadImage rollback: failed to reconcile canvas snapshot', error);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1042
1215
|
_restoreLastMaskReference(previousLastMask) {
|
|
1043
1216
|
if (!this.canvas) {
|
|
1044
1217
|
this._lastMask = null;
|
|
@@ -1116,6 +1289,7 @@ function ensureFabric() {
|
|
|
1116
1289
|
* @private
|
|
1117
1290
|
*/
|
|
1118
1291
|
_setCanvasSizeInt(width, height) {
|
|
1292
|
+
if (!this.canvas) return;
|
|
1119
1293
|
const integerWidth = Math.max(1, Math.round(Number(width) || 1));
|
|
1120
1294
|
const integerHeight = Math.max(1, Math.round(Number(height) || 1));
|
|
1121
1295
|
// Set fabric internal and also style attributes to keep DOM consistent
|
|
@@ -1270,10 +1444,18 @@ function ensureFabric() {
|
|
|
1270
1444
|
|
|
1271
1445
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
1272
1446
|
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
1447
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
1448
|
+
const layoutMode = this._getImageLayoutMode();
|
|
1449
|
+
const shouldReserveNoScrollbarMargin = layoutMode === 'fit' || layoutMode === 'cover';
|
|
1450
|
+
const getNonOverflowAxisSize = (contentSize, effectiveSize, hasOppositeScrollbar) => {
|
|
1451
|
+
const margin = hasOppositeScrollbar ? safetyMargin : (shouldReserveNoScrollbarMargin ? 1 : 0);
|
|
1452
|
+
const safeEffectiveSize = Math.max(1, effectiveSize - margin);
|
|
1453
|
+
return contentSize <= safeEffectiveSize + 0.5 ? safeEffectiveSize : effectiveSize;
|
|
1454
|
+
};
|
|
1273
1455
|
|
|
1274
1456
|
return {
|
|
1275
|
-
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
|
|
1276
|
-
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
|
|
1457
|
+
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : getNonOverflowAxisSize(contentWidth, effectiveWidth, hasVertical),
|
|
1458
|
+
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : getNonOverflowAxisSize(contentHeight, effectiveHeight, hasHorizontal),
|
|
1277
1459
|
viewportWidth: effectiveWidth,
|
|
1278
1460
|
viewportHeight: effectiveHeight,
|
|
1279
1461
|
hasHorizontal,
|
|
@@ -1406,6 +1588,50 @@ function ensureFabric() {
|
|
|
1406
1588
|
}
|
|
1407
1589
|
}
|
|
1408
1590
|
|
|
1591
|
+
_getSerializableStateObjects() {
|
|
1592
|
+
if (!this.canvas) return [];
|
|
1593
|
+
return this.canvas.getObjects().filter(object => !object.isCropRect && !object.maskLabel);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
_restoreHighPrecisionSerializedGeometry(serializedObjects) {
|
|
1597
|
+
if (!Array.isArray(serializedObjects)) return;
|
|
1598
|
+
const fabricObjects = this._getSerializableStateObjects();
|
|
1599
|
+
const numericProperties = [
|
|
1600
|
+
'left',
|
|
1601
|
+
'top',
|
|
1602
|
+
'width',
|
|
1603
|
+
'height',
|
|
1604
|
+
'scaleX',
|
|
1605
|
+
'scaleY',
|
|
1606
|
+
'angle',
|
|
1607
|
+
'skewX',
|
|
1608
|
+
'skewY',
|
|
1609
|
+
'cropX',
|
|
1610
|
+
'cropY',
|
|
1611
|
+
'radius',
|
|
1612
|
+
'rx',
|
|
1613
|
+
'ry',
|
|
1614
|
+
'strokeWidth'
|
|
1615
|
+
];
|
|
1616
|
+
|
|
1617
|
+
serializedObjects.forEach((serializedObject, index) => {
|
|
1618
|
+
const fabricObject = fabricObjects[index];
|
|
1619
|
+
if (!serializedObject || !fabricObject) return;
|
|
1620
|
+
|
|
1621
|
+
numericProperties.forEach(property => {
|
|
1622
|
+
const numericValue = Number(fabricObject[property]);
|
|
1623
|
+
if (Number.isFinite(numericValue)) serializedObject[property] = numericValue;
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
if (Array.isArray(serializedObject.points) && Array.isArray(fabricObject.points)) {
|
|
1627
|
+
serializedObject.points = fabricObject.points.map(point => ({
|
|
1628
|
+
x: Number.isFinite(Number(point && point.x)) ? Number(point.x) : 0,
|
|
1629
|
+
y: Number.isFinite(Number(point && point.y)) ? Number(point.y) : 0
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1409
1635
|
_restoreMaskControls(mask) {
|
|
1410
1636
|
if (!mask) return;
|
|
1411
1637
|
|
|
@@ -1427,7 +1653,7 @@ function ensureFabric() {
|
|
|
1427
1653
|
/**
|
|
1428
1654
|
* Captures editor-owned runtime state that Fabric does not include in canvas JSON.
|
|
1429
1655
|
*
|
|
1430
|
-
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
|
|
1656
|
+
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number, canvasWidth:number, canvasHeight:number}} Serializable editor metadata.
|
|
1431
1657
|
* @private
|
|
1432
1658
|
*/
|
|
1433
1659
|
_serializeEditorMetadata() {
|
|
@@ -1435,13 +1661,17 @@ function ensureFabric() {
|
|
|
1435
1661
|
const currentScale = Number(this.currentScale);
|
|
1436
1662
|
const currentRotation = Number(this.currentRotation);
|
|
1437
1663
|
const maskCounter = Number(this.maskCounter);
|
|
1664
|
+
const canvasWidth = this.canvas ? Number(this.canvas.getWidth()) : NaN;
|
|
1665
|
+
const canvasHeight = this.canvas ? Number(this.canvas.getHeight()) : NaN;
|
|
1438
1666
|
|
|
1439
1667
|
return {
|
|
1440
1668
|
version: 1,
|
|
1441
1669
|
baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
|
|
1442
1670
|
currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
|
|
1443
1671
|
currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
|
|
1444
|
-
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
|
|
1672
|
+
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0,
|
|
1673
|
+
canvasWidth: Number.isFinite(canvasWidth) && canvasWidth > 0 ? Math.round(canvasWidth) : 1,
|
|
1674
|
+
canvasHeight: Number.isFinite(canvasHeight) && canvasHeight > 0 ? Math.round(canvasHeight) : 1
|
|
1445
1675
|
};
|
|
1446
1676
|
}
|
|
1447
1677
|
|
|
@@ -1451,6 +1681,7 @@ function ensureFabric() {
|
|
|
1451
1681
|
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
1452
1682
|
if (Array.isArray(jsonObject.objects)) {
|
|
1453
1683
|
jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
|
|
1684
|
+
this._restoreHighPrecisionSerializedGeometry(jsonObject.objects);
|
|
1454
1685
|
}
|
|
1455
1686
|
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
1456
1687
|
return JSON.stringify(jsonObject);
|
|
@@ -1533,6 +1764,13 @@ function ensureFabric() {
|
|
|
1533
1764
|
return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
|
|
1534
1765
|
}
|
|
1535
1766
|
|
|
1767
|
+
_hasScaledImageEdge(axis) {
|
|
1768
|
+
if (!this.originalImage) return false;
|
|
1769
|
+
const scale = Number(axis === 'y' ? this.originalImage.scaleY : this.originalImage.scaleX);
|
|
1770
|
+
if (!Number.isFinite(scale)) return false;
|
|
1771
|
+
return Math.abs(scale - 1) > 0.01;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1536
1774
|
_getPartialExportEdges(bounds) {
|
|
1537
1775
|
if (!bounds) return null;
|
|
1538
1776
|
const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
|
|
@@ -1542,8 +1780,8 @@ function ensureFabric() {
|
|
|
1542
1780
|
return {
|
|
1543
1781
|
left: this._hasFractionalCanvasEdge(bounds.left),
|
|
1544
1782
|
top: this._hasFractionalCanvasEdge(bounds.top),
|
|
1545
|
-
right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
|
|
1546
|
-
bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
|
|
1783
|
+
right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)) || this._hasScaledImageEdge('x'),
|
|
1784
|
+
bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0)) || this._hasScaledImageEdge('y')
|
|
1547
1785
|
};
|
|
1548
1786
|
}
|
|
1549
1787
|
|
|
@@ -1609,7 +1847,8 @@ function ensureFabric() {
|
|
|
1609
1847
|
* @private
|
|
1610
1848
|
*/
|
|
1611
1849
|
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg', sealPartialEdges = null }) {
|
|
1612
|
-
const safeMultiplier =
|
|
1850
|
+
const safeMultiplier = this._getSafeExportMultiplier(multiplier);
|
|
1851
|
+
this._assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier);
|
|
1613
1852
|
const safeFormat = this._normalizeImageFormat(format);
|
|
1614
1853
|
const exportFormat = safeFormat === 'jpeg' ? 'png' : safeFormat;
|
|
1615
1854
|
let regionDataUrl = this.canvas.toDataURL({
|
|
@@ -1627,6 +1866,30 @@ function ensureFabric() {
|
|
|
1627
1866
|
return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
|
|
1628
1867
|
}
|
|
1629
1868
|
|
|
1869
|
+
_getSafeExportMultiplier(multiplier) {
|
|
1870
|
+
const numericMultiplier = Number(multiplier);
|
|
1871
|
+
if (!Number.isFinite(numericMultiplier) || numericMultiplier <= 0) {
|
|
1872
|
+
throw new Error('Export multiplier must be a finite positive number');
|
|
1873
|
+
}
|
|
1874
|
+
return Math.max(1, numericMultiplier);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
_assertExportPixelBudget(sourceWidth, sourceHeight, safeMultiplier) {
|
|
1878
|
+
const width = Math.max(1, Math.ceil(Number(sourceWidth) || 1));
|
|
1879
|
+
const height = Math.max(1, Math.ceil(Number(sourceHeight) || 1));
|
|
1880
|
+
const outputWidth = Math.ceil(width * safeMultiplier);
|
|
1881
|
+
const outputHeight = Math.ceil(height * safeMultiplier);
|
|
1882
|
+
const outputPixels = outputWidth * outputHeight;
|
|
1883
|
+
const configuredMaxPixels = Number(this.options.maxExportPixels);
|
|
1884
|
+
const maxPixels = Number.isFinite(configuredMaxPixels) && configuredMaxPixels > 0
|
|
1885
|
+
? Math.floor(configuredMaxPixels)
|
|
1886
|
+
: 50000000;
|
|
1887
|
+
|
|
1888
|
+
if (outputPixels > maxPixels) {
|
|
1889
|
+
throw new Error(`Export would create ${outputPixels} pixels, exceeding the configured maxExportPixels limit of ${maxPixels}`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1630
1893
|
async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
|
|
1631
1894
|
const imageElement = await this._createImageElement(dataUrl);
|
|
1632
1895
|
const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
|
|
@@ -1679,6 +1942,7 @@ function ensureFabric() {
|
|
|
1679
1942
|
|
|
1680
1943
|
_decodeBase64Payload(base64Payload) {
|
|
1681
1944
|
const payload = String(base64Payload || '');
|
|
1945
|
+
if (!payload) throw new Error('Data URL base64 payload is empty');
|
|
1682
1946
|
if (typeof atob === 'function') {
|
|
1683
1947
|
return Uint8Array.from(atob(payload), char => char.charCodeAt(0));
|
|
1684
1948
|
}
|
|
@@ -1688,6 +1952,14 @@ function ensureFabric() {
|
|
|
1688
1952
|
throw new Error('Base64 decoding is unavailable');
|
|
1689
1953
|
}
|
|
1690
1954
|
|
|
1955
|
+
_decodeDataUrlPayload(dataUrl) {
|
|
1956
|
+
const match = String(dataUrl || '').match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/i);
|
|
1957
|
+
if (!match || !match[2]) {
|
|
1958
|
+
throw new Error('Export produced an invalid or empty base64 data URL');
|
|
1959
|
+
}
|
|
1960
|
+
return this._decodeBase64Payload(match[2]);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1691
1963
|
/**
|
|
1692
1964
|
* Gets the top-left corner coordinates of the given object.
|
|
1693
1965
|
* Used for geometry calculations (e.g., scale, rotate).
|
|
@@ -1806,26 +2078,57 @@ function ensureFabric() {
|
|
|
1806
2078
|
const currentHeight = this.canvas.getHeight();
|
|
1807
2079
|
let requiredWidth = currentWidth;
|
|
1808
2080
|
let requiredHeight = currentHeight;
|
|
1809
|
-
|
|
2081
|
+
const layoutMode = this._getImageLayoutMode();
|
|
2082
|
+
const usesScrollableFitBounds = layoutMode === 'fit' || layoutMode === 'cover';
|
|
2083
|
+
let contentWidth = 0;
|
|
2084
|
+
let contentHeight = 0;
|
|
2085
|
+
const includeObjectBounds = (fabricObject, objectPadding = 0) => {
|
|
1810
2086
|
if (!fabricObject) return;
|
|
1811
2087
|
if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
|
|
1812
2088
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1813
|
-
|
|
1814
|
-
|
|
2089
|
+
const right = Math.ceil(boundingRect.left + boundingRect.width + objectPadding);
|
|
2090
|
+
const bottom = Math.ceil(boundingRect.top + boundingRect.height + objectPadding);
|
|
2091
|
+
contentWidth = Math.max(contentWidth, right);
|
|
2092
|
+
contentHeight = Math.max(contentHeight, bottom);
|
|
2093
|
+
return { right, bottom };
|
|
2094
|
+
};
|
|
2095
|
+
fabricObjects.forEach(fabricObject => {
|
|
2096
|
+
const bounds = includeObjectBounds(fabricObject, padding);
|
|
2097
|
+
if (!bounds) return;
|
|
2098
|
+
requiredWidth = Math.max(requiredWidth, bounds.right);
|
|
2099
|
+
requiredHeight = Math.max(requiredHeight, bounds.bottom);
|
|
1815
2100
|
});
|
|
1816
|
-
|
|
2101
|
+
if (usesScrollableFitBounds) {
|
|
2102
|
+
if (this.originalImage) includeObjectBounds(this.originalImage, 0);
|
|
2103
|
+
this.canvas.getObjects().forEach(object => {
|
|
2104
|
+
if (object && object.maskId) includeObjectBounds(object, padding);
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
const contentSize = this._getScrollableCanvasSize(
|
|
2108
|
+
Math.max(1, contentWidth),
|
|
2109
|
+
Math.max(1, contentHeight)
|
|
2110
|
+
);
|
|
1817
2111
|
|
|
2112
|
+
const newWidth = contentSize.hasHorizontal
|
|
2113
|
+
? Math.max(currentWidth, contentSize.width)
|
|
2114
|
+
: contentSize.width;
|
|
2115
|
+
const newHeight = contentSize.hasVertical
|
|
2116
|
+
? Math.max(currentHeight, contentSize.height)
|
|
2117
|
+
: contentSize.height;
|
|
2118
|
+
|
|
2119
|
+
if (newWidth !== currentWidth || newHeight !== currentHeight) {
|
|
2120
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
2121
|
+
}
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
1818
2124
|
let minWidth = 0;
|
|
1819
2125
|
let minHeight = 0;
|
|
1820
|
-
if (
|
|
2126
|
+
if (this.containerElement) {
|
|
1821
2127
|
const viewport = this._getContainerViewportSize();
|
|
1822
2128
|
const safetyMargin = this._getScrollSafetyMargin();
|
|
1823
2129
|
|
|
1824
2130
|
minWidth = Math.max(1, viewport.width - safetyMargin);
|
|
1825
2131
|
minHeight = Math.max(1, viewport.height - safetyMargin);
|
|
1826
|
-
} else if (this.containerElement) {
|
|
1827
|
-
minWidth = Math.floor(this.containerElement.clientWidth || 0);
|
|
1828
|
-
minHeight = Math.floor(this.containerElement.clientHeight || 0);
|
|
1829
2132
|
}
|
|
1830
2133
|
const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
|
|
1831
2134
|
const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
|
|
@@ -1837,16 +2140,65 @@ function ensureFabric() {
|
|
|
1837
2140
|
}
|
|
1838
2141
|
}
|
|
1839
2142
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2143
|
+
_captureImageDisplayBounds() {
|
|
2144
|
+
if (!this.originalImage || !this.canvas) return null;
|
|
2145
|
+
this.originalImage.setCoords();
|
|
2146
|
+
const bounds = this.originalImage.getBoundingRect(true, true);
|
|
2147
|
+
const width = Number(bounds && bounds.width);
|
|
2148
|
+
const height = Number(bounds && bounds.height);
|
|
2149
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) return null;
|
|
2150
|
+
|
|
2151
|
+
return {
|
|
2152
|
+
left: Number.isFinite(Number(bounds.left)) ? Number(bounds.left) : 0,
|
|
2153
|
+
top: Number.isFinite(Number(bounds.top)) ? Number(bounds.top) : 0,
|
|
2154
|
+
width,
|
|
2155
|
+
height
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
_restoreImageDisplayBounds(displayBounds) {
|
|
2160
|
+
if (!displayBounds || !this.originalImage || !this.canvas) return;
|
|
2161
|
+
const imageWidth = Number(this.originalImage.width);
|
|
2162
|
+
const imageHeight = Number(this.originalImage.height);
|
|
2163
|
+
if (!Number.isFinite(imageWidth) || imageWidth <= 0 || !Number.isFinite(imageHeight) || imageHeight <= 0) return;
|
|
2164
|
+
|
|
2165
|
+
const scaleX = Number(displayBounds.width) / imageWidth;
|
|
2166
|
+
const scaleY = Number(displayBounds.height) / imageHeight;
|
|
2167
|
+
if (!Number.isFinite(scaleX) || scaleX <= 0 || !Number.isFinite(scaleY) || scaleY <= 0) return;
|
|
2168
|
+
|
|
2169
|
+
const left = Number(displayBounds.left) || 0;
|
|
2170
|
+
const top = Number(displayBounds.top) || 0;
|
|
2171
|
+
const requiredCanvasWidth = Math.max(1, Math.ceil(left + Number(displayBounds.width)));
|
|
2172
|
+
const requiredCanvasHeight = Math.max(1, Math.ceil(top + Number(displayBounds.height)));
|
|
2173
|
+
const currentCanvasWidth = Math.max(1, Math.round(Number(this.canvas.getWidth()) || 1));
|
|
2174
|
+
const currentCanvasHeight = Math.max(1, Math.round(Number(this.canvas.getHeight()) || 1));
|
|
2175
|
+
const layoutMode = this._getImageLayoutMode();
|
|
2176
|
+
if (layoutMode === 'fit' || layoutMode === 'cover') {
|
|
2177
|
+
const contentSize = this._getScrollableCanvasSize(requiredCanvasWidth, requiredCanvasHeight);
|
|
2178
|
+
if (contentSize.width !== currentCanvasWidth || contentSize.height !== currentCanvasHeight) {
|
|
2179
|
+
this._setCanvasSizeInt(contentSize.width, contentSize.height);
|
|
2180
|
+
}
|
|
2181
|
+
} else if (requiredCanvasWidth > currentCanvasWidth || requiredCanvasHeight > currentCanvasHeight) {
|
|
2182
|
+
this._setCanvasSizeInt(
|
|
2183
|
+
Math.max(currentCanvasWidth, requiredCanvasWidth),
|
|
2184
|
+
Math.max(currentCanvasHeight, requiredCanvasHeight)
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
this.originalImage.set({
|
|
2189
|
+
originX: 'left',
|
|
2190
|
+
originY: 'top',
|
|
2191
|
+
left,
|
|
2192
|
+
top,
|
|
2193
|
+
scaleX,
|
|
2194
|
+
scaleY
|
|
2195
|
+
});
|
|
2196
|
+
this.originalImage.setCoords();
|
|
2197
|
+
this.baseImageScale = scaleX;
|
|
2198
|
+
this.currentScale = 1;
|
|
2199
|
+
this.currentRotation = Number(this.originalImage.angle) || 0;
|
|
2200
|
+
this._updateInputs();
|
|
2201
|
+
this.canvas.renderAll();
|
|
1850
2202
|
}
|
|
1851
2203
|
|
|
1852
2204
|
/**
|
|
@@ -1862,7 +2214,14 @@ function ensureFabric() {
|
|
|
1862
2214
|
} catch (error) {
|
|
1863
2215
|
return Promise.reject(error);
|
|
1864
2216
|
}
|
|
1865
|
-
return this.animationQueue.add(() =>
|
|
2217
|
+
return this.animationQueue.add(async () => {
|
|
2218
|
+
const operationToken = this._beginBusyOperation('scaleImage');
|
|
2219
|
+
try {
|
|
2220
|
+
await this._scaleImageImpl(factor, this._withInternalOperationOptions(operationToken, options));
|
|
2221
|
+
} finally {
|
|
2222
|
+
this._endBusyOperation(operationToken);
|
|
2223
|
+
}
|
|
2224
|
+
})
|
|
1866
2225
|
.finally(() => {
|
|
1867
2226
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
1868
2227
|
});
|
|
@@ -1904,10 +2263,17 @@ function ensureFabric() {
|
|
|
1904
2263
|
if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
|
|
1905
2264
|
}
|
|
1906
2265
|
|
|
2266
|
+
_isCropModeAllowedOperation(operationName) {
|
|
2267
|
+
return operationName === 'applyCrop' || operationName === 'cancelCrop';
|
|
2268
|
+
}
|
|
2269
|
+
|
|
1907
2270
|
_assertIdleForOperation(operationName, options = {}) {
|
|
1908
2271
|
this._assertEditorAvailable(operationName);
|
|
1909
2272
|
const isOwnInternalOperation = this._isOwnInternalOperation(options);
|
|
1910
|
-
if (this.
|
|
2273
|
+
if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
|
|
2274
|
+
throw new Error(`${operationName} cannot run while crop mode is active`);
|
|
2275
|
+
}
|
|
2276
|
+
if ((this.isAnimating || (this.animationQueue && this.animationQueue.isBusy())) && !isOwnInternalOperation) {
|
|
1911
2277
|
throw new Error(`${operationName} cannot run while an animation is running`);
|
|
1912
2278
|
}
|
|
1913
2279
|
if (this._isLoading && !isOwnInternalOperation) {
|
|
@@ -1920,10 +2286,14 @@ function ensureFabric() {
|
|
|
1920
2286
|
|
|
1921
2287
|
_assertCanQueueAnimation(operationName, options = {}) {
|
|
1922
2288
|
this._assertEditorAvailable(operationName);
|
|
1923
|
-
|
|
2289
|
+
const isOwnInternalOperation = this._isOwnInternalOperation(options);
|
|
2290
|
+
if (this._cropMode && !this._isCropModeAllowedOperation(operationName) && !isOwnInternalOperation) {
|
|
2291
|
+
throw new Error(`${operationName} cannot run while crop mode is active`);
|
|
2292
|
+
}
|
|
2293
|
+
if (this._isLoading && !isOwnInternalOperation) {
|
|
1924
2294
|
throw new Error(`${operationName} cannot run while an image is loading`);
|
|
1925
2295
|
}
|
|
1926
|
-
if (this._activeOperationToken && !
|
|
2296
|
+
if (this._activeOperationToken && !isOwnInternalOperation) {
|
|
1927
2297
|
throw new Error(`${operationName} cannot run while ${this._activeOperationName || 'another operation'} is running`);
|
|
1928
2298
|
}
|
|
1929
2299
|
}
|
|
@@ -2026,7 +2396,7 @@ function ensureFabric() {
|
|
|
2026
2396
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
2027
2397
|
|
|
2028
2398
|
this._updateInputs();
|
|
2029
|
-
if (saveHistory) this.saveState();
|
|
2399
|
+
if (saveHistory) this.saveState(options);
|
|
2030
2400
|
} finally {
|
|
2031
2401
|
if (didStartAnimation) {
|
|
2032
2402
|
this.isAnimating = false;
|
|
@@ -2049,7 +2419,14 @@ function ensureFabric() {
|
|
|
2049
2419
|
} catch (error) {
|
|
2050
2420
|
return Promise.reject(error);
|
|
2051
2421
|
}
|
|
2052
|
-
return this.animationQueue.add(() =>
|
|
2422
|
+
return this.animationQueue.add(async () => {
|
|
2423
|
+
const operationToken = this._beginBusyOperation('rotateImage');
|
|
2424
|
+
try {
|
|
2425
|
+
await this._rotateImageImpl(degrees, this._withInternalOperationOptions(operationToken, options));
|
|
2426
|
+
} finally {
|
|
2427
|
+
this._endBusyOperation(operationToken);
|
|
2428
|
+
}
|
|
2429
|
+
})
|
|
2053
2430
|
.finally(() => {
|
|
2054
2431
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
2055
2432
|
});
|
|
@@ -2100,7 +2477,7 @@ function ensureFabric() {
|
|
|
2100
2477
|
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
2101
2478
|
|
|
2102
2479
|
this._updateInputs();
|
|
2103
|
-
if (saveHistory) this.saveState();
|
|
2480
|
+
if (saveHistory) this.saveState(options);
|
|
2104
2481
|
didCompleteRotation = true;
|
|
2105
2482
|
} finally {
|
|
2106
2483
|
if (!didCompleteRotation && !this._disposed && image) {
|
|
@@ -2129,11 +2506,23 @@ function ensureFabric() {
|
|
|
2129
2506
|
}
|
|
2130
2507
|
|
|
2131
2508
|
return this.animationQueue.add(async () => {
|
|
2509
|
+
const operationToken = this._beginBusyOperation('resetImageTransform');
|
|
2132
2510
|
const before = this._lastSnapshot || this._captureCanvasStateOrThrow('resetImageTransform');
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2511
|
+
try {
|
|
2512
|
+
await this._scaleImageImpl(1, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
|
|
2513
|
+
await this._rotateImageImpl(0, this._withInternalOperationOptions(operationToken, { saveHistory: false }));
|
|
2514
|
+
const after = this._captureCanvasStateOrThrow('resetImageTransform');
|
|
2515
|
+
this._pushStateTransition(before, after);
|
|
2516
|
+
} catch (error) {
|
|
2517
|
+
try {
|
|
2518
|
+
await this.loadFromState(before, this._withInternalOperationOptions(operationToken));
|
|
2519
|
+
} catch (restoreError) {
|
|
2520
|
+
this._reportError('resetImageTransform rollback failed', restoreError);
|
|
2521
|
+
}
|
|
2522
|
+
throw error;
|
|
2523
|
+
} finally {
|
|
2524
|
+
this._endBusyOperation(operationToken);
|
|
2525
|
+
}
|
|
2137
2526
|
}).finally(() => {
|
|
2138
2527
|
if (!this._disposed && this.canvas) this._updateUI();
|
|
2139
2528
|
}).catch(error => {
|
|
@@ -2159,8 +2548,13 @@ function ensureFabric() {
|
|
|
2159
2548
|
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
2160
2549
|
* @public
|
|
2161
2550
|
*/
|
|
2162
|
-
loadFromState(serializedState) {
|
|
2551
|
+
loadFromState(serializedState, options = {}) {
|
|
2163
2552
|
if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
|
|
2553
|
+
try {
|
|
2554
|
+
this._assertIdleForOperation('loadFromState', options);
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
return Promise.reject(error);
|
|
2557
|
+
}
|
|
2164
2558
|
if (this._cropMode || this._cropRect) {
|
|
2165
2559
|
this._removeCropRect();
|
|
2166
2560
|
this._restoreCropObjectState();
|
|
@@ -2177,6 +2571,13 @@ function ensureFabric() {
|
|
|
2177
2571
|
? JSON.parse(serializedState)
|
|
2178
2572
|
: serializedState;
|
|
2179
2573
|
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
2574
|
+
const restoredCanvasWidth = Number(editorMetadata && editorMetadata.canvasWidth);
|
|
2575
|
+
const restoredCanvasHeight = Number(editorMetadata && editorMetadata.canvasHeight);
|
|
2576
|
+
const hasRestoredCanvasSize =
|
|
2577
|
+
Number.isFinite(restoredCanvasWidth) &&
|
|
2578
|
+
restoredCanvasWidth > 0 &&
|
|
2579
|
+
Number.isFinite(restoredCanvasHeight) &&
|
|
2580
|
+
restoredCanvasHeight > 0;
|
|
2180
2581
|
if (
|
|
2181
2582
|
editorMetadata &&
|
|
2182
2583
|
Object.prototype.hasOwnProperty.call(editorMetadata, 'version') &&
|
|
@@ -2185,7 +2586,7 @@ function ensureFabric() {
|
|
|
2185
2586
|
this._reportWarning(`loadFromState: unsupported editor metadata version ${editorMetadata.version}`);
|
|
2186
2587
|
}
|
|
2187
2588
|
|
|
2188
|
-
|
|
2589
|
+
const finishLoad = async () => {
|
|
2189
2590
|
try {
|
|
2190
2591
|
if (this._disposed || !this.canvas) {
|
|
2191
2592
|
reject(new Error('Editor was disposed while loading state'));
|
|
@@ -2228,6 +2629,12 @@ function ensureFabric() {
|
|
|
2228
2629
|
this.currentRotation = 0;
|
|
2229
2630
|
}
|
|
2230
2631
|
|
|
2632
|
+
if (hasRestoredCanvasSize) {
|
|
2633
|
+
this._setCanvasSizeInt(restoredCanvasWidth, restoredCanvasHeight);
|
|
2634
|
+
} else if (this.originalImage && this._shouldResizeCanvasToContentBounds()) {
|
|
2635
|
+
this._updateCanvasSizeToImageBounds();
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2231
2638
|
const masks = canvasObjects.filter(object => object.maskId);
|
|
2232
2639
|
masks.forEach(mask => {
|
|
2233
2640
|
this._restoreMaskControls(mask);
|
|
@@ -2259,7 +2666,9 @@ function ensureFabric() {
|
|
|
2259
2666
|
this._reportError('loadFromState() failed', callbackError);
|
|
2260
2667
|
reject(callbackError);
|
|
2261
2668
|
}
|
|
2262
|
-
}
|
|
2669
|
+
};
|
|
2670
|
+
|
|
2671
|
+
this.canvas.loadFromJSON(state, () => { void finishLoad(); });
|
|
2263
2672
|
|
|
2264
2673
|
} catch (error) {
|
|
2265
2674
|
this._reportError('loadFromState() failed', error);
|
|
@@ -2331,9 +2740,17 @@ function ensureFabric() {
|
|
|
2331
2740
|
* @returns {void}
|
|
2332
2741
|
* @public
|
|
2333
2742
|
*/
|
|
2334
|
-
saveState() {
|
|
2743
|
+
saveState(options = {}) {
|
|
2335
2744
|
if (!this.canvas) return;
|
|
2336
2745
|
|
|
2746
|
+
try {
|
|
2747
|
+
this._assertIdleForOperation('saveState', options);
|
|
2748
|
+
} catch (error) {
|
|
2749
|
+
this._reportError('saveState blocked', error);
|
|
2750
|
+
this._updateUI();
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2337
2754
|
try {
|
|
2338
2755
|
const after = this._captureCanvasStateOrThrow('saveState');
|
|
2339
2756
|
const before = this._lastSnapshot || after;
|
|
@@ -2341,14 +2758,14 @@ function ensureFabric() {
|
|
|
2341
2758
|
let executedOnce = false;
|
|
2342
2759
|
|
|
2343
2760
|
const command = new Command(
|
|
2344
|
-
() => {
|
|
2761
|
+
(commandOptions = {}) => {
|
|
2345
2762
|
if (executedOnce) {
|
|
2346
|
-
return this.loadFromState(after);
|
|
2763
|
+
return this.loadFromState(after, commandOptions);
|
|
2347
2764
|
}
|
|
2348
2765
|
executedOnce = true;
|
|
2349
2766
|
return undefined;
|
|
2350
2767
|
},
|
|
2351
|
-
() => this.loadFromState(before)
|
|
2768
|
+
(commandOptions = {}) => this.loadFromState(before, commandOptions)
|
|
2352
2769
|
);
|
|
2353
2770
|
|
|
2354
2771
|
this.historyManager.execute(command);
|
|
@@ -2380,8 +2797,8 @@ function ensureFabric() {
|
|
|
2380
2797
|
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
2381
2798
|
|
|
2382
2799
|
const command = new Command(
|
|
2383
|
-
() => this.loadFromState(after),
|
|
2384
|
-
() => this.loadFromState(before)
|
|
2800
|
+
(commandOptions = {}) => this.loadFromState(after, commandOptions),
|
|
2801
|
+
(commandOptions = {}) => this.loadFromState(before, commandOptions)
|
|
2385
2802
|
);
|
|
2386
2803
|
this.historyManager.push(command);
|
|
2387
2804
|
this._lastSnapshot = after;
|
|
@@ -2395,8 +2812,17 @@ function ensureFabric() {
|
|
|
2395
2812
|
* @public
|
|
2396
2813
|
*/
|
|
2397
2814
|
undo() {
|
|
2398
|
-
|
|
2815
|
+
try {
|
|
2816
|
+
this._assertIdleForOperation('undo');
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
return Promise.reject(error);
|
|
2819
|
+
}
|
|
2820
|
+
const operationToken = this._beginBusyOperation('undo');
|
|
2821
|
+
return this.historyManager.undo(this._withInternalOperationOptions(operationToken))
|
|
2399
2822
|
.then(() => { this._updateUI(); })
|
|
2823
|
+
.finally(() => {
|
|
2824
|
+
this._endBusyOperation(operationToken);
|
|
2825
|
+
})
|
|
2400
2826
|
.catch(error => {
|
|
2401
2827
|
this._reportError('undo failed', error);
|
|
2402
2828
|
throw error;
|
|
@@ -2410,8 +2836,17 @@ function ensureFabric() {
|
|
|
2410
2836
|
* @public
|
|
2411
2837
|
*/
|
|
2412
2838
|
redo() {
|
|
2413
|
-
|
|
2839
|
+
try {
|
|
2840
|
+
this._assertIdleForOperation('redo');
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
return Promise.reject(error);
|
|
2843
|
+
}
|
|
2844
|
+
const operationToken = this._beginBusyOperation('redo');
|
|
2845
|
+
return this.historyManager.redo(this._withInternalOperationOptions(operationToken))
|
|
2414
2846
|
.then(() => { this._updateUI(); })
|
|
2847
|
+
.finally(() => {
|
|
2848
|
+
this._endBusyOperation(operationToken);
|
|
2849
|
+
})
|
|
2415
2850
|
.catch(error => {
|
|
2416
2851
|
this._reportError('redo failed', error);
|
|
2417
2852
|
throw error;
|
|
@@ -2420,12 +2855,7 @@ function ensureFabric() {
|
|
|
2420
2855
|
|
|
2421
2856
|
_rebindMaskEvents(mask) {
|
|
2422
2857
|
if (!mask) return;
|
|
2423
|
-
|
|
2424
|
-
try {
|
|
2425
|
-
mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
|
|
2426
|
-
mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
|
|
2427
|
-
} catch (error) { void error; }
|
|
2428
|
-
}
|
|
2858
|
+
this._cleanupMaskEvents(mask);
|
|
2429
2859
|
|
|
2430
2860
|
const metadata = {};
|
|
2431
2861
|
if (!Number.isFinite(Number(mask.originalAlpha))) {
|
|
@@ -2456,6 +2886,19 @@ function ensureFabric() {
|
|
|
2456
2886
|
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
2457
2887
|
}
|
|
2458
2888
|
|
|
2889
|
+
_cleanupMaskEvents(mask) {
|
|
2890
|
+
if (!mask || !mask.__imageEditorMaskHandlers) return;
|
|
2891
|
+
try {
|
|
2892
|
+
if (typeof mask.off === 'function') {
|
|
2893
|
+
mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
|
|
2894
|
+
mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
|
|
2895
|
+
}
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
this._reportWarning('Mask event cleanup failed', error);
|
|
2898
|
+
}
|
|
2899
|
+
try { delete mask.__imageEditorMaskHandlers; } catch (error) { this._reportWarning('Mask event metadata cleanup failed', error); }
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2459
2902
|
/**
|
|
2460
2903
|
* Creates a mask and adds it to the canvas.
|
|
2461
2904
|
*
|
|
@@ -2527,21 +2970,49 @@ function ensureFabric() {
|
|
|
2527
2970
|
return value != null ? value : fallback;
|
|
2528
2971
|
};
|
|
2529
2972
|
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2973
|
+
const rejectInvalidMask = (message, error = null) => {
|
|
2974
|
+
this._reportWarning(`createMask: ${message}`, error);
|
|
2975
|
+
return null;
|
|
2976
|
+
};
|
|
2977
|
+
|
|
2978
|
+
const resolveNumber = (value, fallback, axis, fieldName, constraints = {}) => {
|
|
2979
|
+
const resolvedValue = resolveValue(value, fallback, axis);
|
|
2980
|
+
const numericValue = Number(resolvedValue);
|
|
2981
|
+
if (!Number.isFinite(numericValue)) {
|
|
2982
|
+
throw new Error(`${fieldName} must be a finite number`);
|
|
2983
|
+
}
|
|
2984
|
+
if (constraints.positive && numericValue <= 0) {
|
|
2985
|
+
throw new Error(`${fieldName} must be greater than 0`);
|
|
2986
|
+
}
|
|
2987
|
+
if (constraints.nonNegative && numericValue < 0) {
|
|
2988
|
+
throw new Error(`${fieldName} must be 0 or greater`);
|
|
2989
|
+
}
|
|
2990
|
+
return numericValue;
|
|
2991
|
+
};
|
|
2992
|
+
|
|
2993
|
+
try {
|
|
2994
|
+
maskConfig.gap = resolveNumber(maskConfig.gap, 5, 'width', 'gap', { nonNegative: true });
|
|
2995
|
+
maskConfig.width = resolveNumber(maskConfig.width, this.options.defaultMaskWidth, 'width', 'width', { positive: true });
|
|
2996
|
+
maskConfig.height = resolveNumber(maskConfig.height, this.options.defaultMaskHeight, 'height', 'height', { positive: true });
|
|
2997
|
+
maskConfig.angle = resolveNumber(maskConfig.angle, 0, 'width', 'angle');
|
|
2998
|
+
maskConfig.alpha = Math.max(0, Math.min(1, resolveNumber(maskConfig.alpha, 0.5, 'width', 'alpha')));
|
|
2999
|
+
|
|
3000
|
+
if (maskConfig.left === undefined && this._lastMask) {
|
|
3001
|
+
const previousMask = this._lastMask;
|
|
3002
|
+
if (typeof previousMask.setCoords === 'function') previousMask.setCoords();
|
|
3003
|
+
const previousBounds = typeof previousMask.getBoundingRect === 'function'
|
|
3004
|
+
? previousMask.getBoundingRect(true, true)
|
|
3005
|
+
: { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
|
|
3006
|
+
left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
|
|
3007
|
+
top = Math.round(previousBounds.top ?? firstOffset);
|
|
3008
|
+
} else {
|
|
3009
|
+
left = resolveNumber(maskConfig.left, firstOffset, 'width', 'left');
|
|
3010
|
+
top = resolveNumber(maskConfig.top, firstOffset, 'height', 'top');
|
|
3011
|
+
}
|
|
3012
|
+
} catch (error) {
|
|
3013
|
+
return rejectInvalidMask('invalid numeric configuration', error);
|
|
2541
3014
|
}
|
|
2542
3015
|
|
|
2543
|
-
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
|
|
2544
|
-
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
|
|
2545
3016
|
maskConfig.left = left;
|
|
2546
3017
|
maskConfig.top = top;
|
|
2547
3018
|
|
|
@@ -2551,9 +3022,14 @@ function ensureFabric() {
|
|
|
2551
3022
|
} else {
|
|
2552
3023
|
switch (shapeType) {
|
|
2553
3024
|
case 'circle':
|
|
3025
|
+
try {
|
|
3026
|
+
maskConfig.radius = resolveNumber(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min', 'radius', { positive: true });
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
return rejectInvalidMask('invalid circle radius', error);
|
|
3029
|
+
}
|
|
2554
3030
|
mask = new fabric.Circle({
|
|
2555
3031
|
left, top,
|
|
2556
|
-
radius:
|
|
3032
|
+
radius: maskConfig.radius,
|
|
2557
3033
|
fill: maskConfig.color,
|
|
2558
3034
|
opacity: maskConfig.alpha,
|
|
2559
3035
|
angle: maskConfig.angle,
|
|
@@ -2561,10 +3037,16 @@ function ensureFabric() {
|
|
|
2561
3037
|
});
|
|
2562
3038
|
break;
|
|
2563
3039
|
case 'ellipse':
|
|
3040
|
+
try {
|
|
3041
|
+
maskConfig.rx = resolveNumber(maskConfig.rx, maskConfig.width / 2, 'width', 'rx', { positive: true });
|
|
3042
|
+
maskConfig.ry = resolveNumber(maskConfig.ry, maskConfig.height / 2, 'height', 'ry', { positive: true });
|
|
3043
|
+
} catch (error) {
|
|
3044
|
+
return rejectInvalidMask('invalid ellipse radius', error);
|
|
3045
|
+
}
|
|
2564
3046
|
mask = new fabric.Ellipse({
|
|
2565
3047
|
left, top,
|
|
2566
|
-
rx:
|
|
2567
|
-
ry:
|
|
3048
|
+
rx: maskConfig.rx,
|
|
3049
|
+
ry: maskConfig.ry,
|
|
2568
3050
|
fill: maskConfig.color,
|
|
2569
3051
|
opacity: maskConfig.alpha,
|
|
2570
3052
|
angle: maskConfig.angle,
|
|
@@ -2573,11 +3055,20 @@ function ensureFabric() {
|
|
|
2573
3055
|
break;
|
|
2574
3056
|
case 'polygon': {
|
|
2575
3057
|
let polygonPoints = maskConfig.points || [];
|
|
2576
|
-
if (Array.isArray(polygonPoints)
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3058
|
+
if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
|
|
3059
|
+
return rejectInvalidMask('polygon masks require at least three points');
|
|
3060
|
+
}
|
|
3061
|
+
try {
|
|
3062
|
+
polygonPoints = polygonPoints.map(point => {
|
|
3063
|
+
const x = Number(Array.isArray(point) ? point[0] : point.x);
|
|
3064
|
+
const y = Number(Array.isArray(point) ? point[1] : point.y);
|
|
3065
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
3066
|
+
throw new Error('polygon point coordinates must be finite numbers');
|
|
3067
|
+
}
|
|
3068
|
+
return { x, y };
|
|
3069
|
+
});
|
|
3070
|
+
} catch (error) {
|
|
3071
|
+
return rejectInvalidMask('invalid polygon points', error);
|
|
2581
3072
|
}
|
|
2582
3073
|
mask = new fabric.Polygon(polygonPoints, {
|
|
2583
3074
|
left, top,
|
|
@@ -2590,10 +3081,16 @@ function ensureFabric() {
|
|
|
2590
3081
|
}
|
|
2591
3082
|
case 'rect':
|
|
2592
3083
|
default:
|
|
3084
|
+
try {
|
|
3085
|
+
if (maskConfig.rx != null) maskConfig.rx = resolveNumber(maskConfig.rx, 0, 'width', 'rx', { nonNegative: true });
|
|
3086
|
+
if (maskConfig.ry != null) maskConfig.ry = resolveNumber(maskConfig.ry, 0, 'height', 'ry', { nonNegative: true });
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
return rejectInvalidMask('invalid rectangle corner radius', error);
|
|
3089
|
+
}
|
|
2593
3090
|
mask = new fabric.Rect({
|
|
2594
3091
|
left, top,
|
|
2595
|
-
width:
|
|
2596
|
-
height:
|
|
3092
|
+
width: maskConfig.width,
|
|
3093
|
+
height: maskConfig.height,
|
|
2597
3094
|
fill: maskConfig.color,
|
|
2598
3095
|
opacity: maskConfig.alpha,
|
|
2599
3096
|
angle: maskConfig.angle,
|
|
@@ -2634,12 +3131,12 @@ function ensureFabric() {
|
|
|
2634
3131
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
2635
3132
|
});
|
|
2636
3133
|
this._rebindMaskEvents(mask);
|
|
2637
|
-
this.
|
|
3134
|
+
this._expandCanvasToFitObjects([mask]);
|
|
2638
3135
|
|
|
2639
3136
|
// Store placement values so the next mask can be positioned beside this one.
|
|
2640
3137
|
this._lastMaskInitialLeft = left;
|
|
2641
3138
|
this._lastMaskInitialTop = top;
|
|
2642
|
-
this._lastMaskInitialWidth =
|
|
3139
|
+
this._lastMaskInitialWidth = maskConfig.width;
|
|
2643
3140
|
|
|
2644
3141
|
const maskId = ++this.maskCounter;
|
|
2645
3142
|
mask.set({
|
|
@@ -2686,6 +3183,7 @@ function ensureFabric() {
|
|
|
2686
3183
|
this.canvas.discardActiveObject();
|
|
2687
3184
|
selectedMasks.forEach(mask => {
|
|
2688
3185
|
this._removeLabelForMask(mask);
|
|
3186
|
+
this._cleanupMaskEvents(mask);
|
|
2689
3187
|
this.canvas.remove(mask);
|
|
2690
3188
|
});
|
|
2691
3189
|
|
|
@@ -2712,7 +3210,10 @@ function ensureFabric() {
|
|
|
2712
3210
|
const saveHistory = options.saveHistory !== false;
|
|
2713
3211
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2714
3212
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
2715
|
-
masks.forEach(mask =>
|
|
3213
|
+
masks.forEach(mask => {
|
|
3214
|
+
this._cleanupMaskEvents(mask);
|
|
3215
|
+
this.canvas.remove(mask);
|
|
3216
|
+
});
|
|
2716
3217
|
this.canvas.discardActiveObject();
|
|
2717
3218
|
this._lastMask = null;
|
|
2718
3219
|
this._lastMaskInitialLeft = null;
|
|
@@ -2777,7 +3278,9 @@ function ensureFabric() {
|
|
|
2777
3278
|
if (backup.visible !== undefined) backup.label.set({ visible: backup.visible });
|
|
2778
3279
|
if (backup.labelInCanvas) this.canvas.bringToFront(backup.label);
|
|
2779
3280
|
this._syncMaskLabel(backup.mask);
|
|
2780
|
-
} catch (error) {
|
|
3281
|
+
} catch (error) {
|
|
3282
|
+
this._reportWarning('restoreMaskLabelBackups: failed to restore mask label', error);
|
|
3283
|
+
}
|
|
2781
3284
|
});
|
|
2782
3285
|
}
|
|
2783
3286
|
|
|
@@ -2919,7 +3422,6 @@ function ensureFabric() {
|
|
|
2919
3422
|
try {
|
|
2920
3423
|
if (canvasObjectSet.has(label)) {
|
|
2921
3424
|
this.canvas.remove(label);
|
|
2922
|
-
canvasObjectSet.delete(label);
|
|
2923
3425
|
}
|
|
2924
3426
|
} catch (error) { void error; }
|
|
2925
3427
|
});
|
|
@@ -3084,6 +3586,7 @@ function ensureFabric() {
|
|
|
3084
3586
|
this._assertIdleForOperation('mergeMasks');
|
|
3085
3587
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
3086
3588
|
if (!masks.length) return;
|
|
3589
|
+
const beforeImageDisplayBounds = this._captureImageDisplayBounds();
|
|
3087
3590
|
const beforeJson = this._serializeCanvasState();
|
|
3088
3591
|
const operationToken = this._beginBusyOperation('mergeMasks');
|
|
3089
3592
|
|
|
@@ -3104,12 +3607,13 @@ function ensureFabric() {
|
|
|
3104
3607
|
preserveScroll: true,
|
|
3105
3608
|
resetMaskCounter: false
|
|
3106
3609
|
}));
|
|
3610
|
+
this._restoreImageDisplayBounds(beforeImageDisplayBounds);
|
|
3107
3611
|
const afterJson = this._serializeCanvasState();
|
|
3108
3612
|
this._pushStateTransition(beforeJson, afterJson);
|
|
3109
3613
|
} catch (error) {
|
|
3110
3614
|
this._reportError('merge error', error);
|
|
3111
3615
|
try {
|
|
3112
|
-
await this.loadFromState(beforeJson);
|
|
3616
|
+
await this.loadFromState(beforeJson, this._withInternalOperationOptions(operationToken));
|
|
3113
3617
|
} catch (restoreError) {
|
|
3114
3618
|
this._reportError('mergeMasks rollback failed', restoreError);
|
|
3115
3619
|
}
|
|
@@ -3171,13 +3675,19 @@ function ensureFabric() {
|
|
|
3171
3675
|
*/
|
|
3172
3676
|
async exportImageBase64(options = {}) {
|
|
3173
3677
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
3678
|
+
options = options || {};
|
|
3174
3679
|
this._assertIdleForOperation('exportImageBase64', options);
|
|
3680
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
3681
|
+
const operationToken = isNestedOperation
|
|
3682
|
+
? this._getInternalOperationToken(options)
|
|
3683
|
+
: this._beginBusyOperation('exportImageBase64');
|
|
3175
3684
|
const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
3176
3685
|
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
3177
3686
|
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
3178
3687
|
const format = this._normalizeImageFormat(options.fileType || options.format);
|
|
3179
3688
|
|
|
3180
|
-
|
|
3689
|
+
try {
|
|
3690
|
+
if (!exportImageArea) {
|
|
3181
3691
|
const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
|
|
3182
3692
|
const editableMasks = this.canvas.getObjects().filter(object => object.maskId);
|
|
3183
3693
|
const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
|
|
@@ -3209,16 +3719,15 @@ function ensureFabric() {
|
|
|
3209
3719
|
this._restoreActiveObjectBackup(activeObjectBackup);
|
|
3210
3720
|
this.canvas.renderAll();
|
|
3211
3721
|
}
|
|
3212
|
-
|
|
3722
|
+
}
|
|
3213
3723
|
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3724
|
+
// Render masks as export shapes without mutating their editable styles.
|
|
3725
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
3726
|
+
const maskStyleBackups = this._captureMaskExportBackups(masks);
|
|
3727
|
+
const labelBackups = this._captureMaskLabelBackups(masks);
|
|
3728
|
+
const activeObjectBackup = this._captureActiveObjectBackup();
|
|
3219
3729
|
|
|
3220
|
-
|
|
3221
|
-
try {
|
|
3730
|
+
try {
|
|
3222
3731
|
// Labels are UI overlays and should not be part of the flattened export.
|
|
3223
3732
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
3224
3733
|
this.canvas.discardActiveObject();
|
|
@@ -3236,8 +3745,7 @@ function ensureFabric() {
|
|
|
3236
3745
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
3237
3746
|
const exportRegion = this._getClampedCanvasRegion(imageBounds);
|
|
3238
3747
|
|
|
3239
|
-
|
|
3240
|
-
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
3748
|
+
return await this._exportCanvasRegionToDataURL({
|
|
3241
3749
|
...exportRegion,
|
|
3242
3750
|
multiplier,
|
|
3243
3751
|
quality,
|
|
@@ -3250,8 +3758,9 @@ function ensureFabric() {
|
|
|
3250
3758
|
this._restoreActiveObjectBackup(activeObjectBackup);
|
|
3251
3759
|
this.canvas.renderAll();
|
|
3252
3760
|
}
|
|
3253
|
-
|
|
3254
|
-
|
|
3761
|
+
} finally {
|
|
3762
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
3763
|
+
}
|
|
3255
3764
|
}
|
|
3256
3765
|
|
|
3257
3766
|
/**
|
|
@@ -3285,7 +3794,12 @@ function ensureFabric() {
|
|
|
3285
3794
|
*/
|
|
3286
3795
|
async exportImageFile(options = {}) {
|
|
3287
3796
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
3288
|
-
|
|
3797
|
+
options = options || {};
|
|
3798
|
+
this._assertIdleForOperation('exportImageFile', options);
|
|
3799
|
+
const isNestedOperation = this._isOwnInternalOperation(options);
|
|
3800
|
+
const operationToken = isNestedOperation
|
|
3801
|
+
? this._getInternalOperationToken(options)
|
|
3802
|
+
: this._beginBusyOperation('exportImageFile');
|
|
3289
3803
|
const {
|
|
3290
3804
|
mergeMask = true,
|
|
3291
3805
|
fileType = 'jpeg',
|
|
@@ -3297,52 +3811,56 @@ function ensureFabric() {
|
|
|
3297
3811
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
3298
3812
|
const normalizedQuality = this._normalizeQuality(quality);
|
|
3299
3813
|
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3814
|
+
try {
|
|
3815
|
+
// Generate the data URL in the requested export mode.
|
|
3816
|
+
let imageBase64;
|
|
3817
|
+
if (mergeMask) {
|
|
3818
|
+
imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
|
|
3819
|
+
exportImageArea: true,
|
|
3820
|
+
multiplier,
|
|
3821
|
+
quality: normalizedQuality,
|
|
3822
|
+
fileType: safeFileType
|
|
3823
|
+
}));
|
|
3824
|
+
} else {
|
|
3825
|
+
imageBase64 = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
|
|
3826
|
+
exportImageArea: false,
|
|
3827
|
+
multiplier,
|
|
3828
|
+
quality: normalizedQuality,
|
|
3829
|
+
fileType: safeFileType
|
|
3830
|
+
}));
|
|
3831
|
+
}
|
|
3317
3832
|
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3833
|
+
// Convert to the required image format
|
|
3834
|
+
let imageDataUrl = imageBase64;
|
|
3835
|
+
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
3836
|
+
// Redraw the exported data URL when the browser returned a different image format.
|
|
3837
|
+
imageDataUrl = await new Promise((resolve, reject) => {
|
|
3838
|
+
const imageElement = new window.Image();
|
|
3839
|
+
imageElement.crossOrigin = "Anonymous";
|
|
3840
|
+
imageElement.onload = () => {
|
|
3841
|
+
try {
|
|
3842
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
3843
|
+
offscreenCanvas.width = imageElement.width;
|
|
3844
|
+
offscreenCanvas.height = imageElement.height;
|
|
3845
|
+
const context = offscreenCanvas.getContext('2d');
|
|
3846
|
+
if (!context) throw new Error('Unable to create 2D canvas context for export conversion');
|
|
3847
|
+
context.drawImage(imageElement, 0, 0);
|
|
3848
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
|
|
3849
|
+
resolve(convertedDataUrl);
|
|
3850
|
+
} catch (error) { reject(error); }
|
|
3851
|
+
};
|
|
3852
|
+
imageElement.onerror = reject;
|
|
3853
|
+
imageElement.src = imageBase64;
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3341
3856
|
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3857
|
+
// Convert the final data URL to a File with the requested MIME type.
|
|
3858
|
+
const bytes = this._decodeDataUrlPayload(imageDataUrl);
|
|
3859
|
+
const mime = `image/${safeFileType}`;
|
|
3860
|
+
return new File([bytes], fileName, { type: mime });
|
|
3861
|
+
} finally {
|
|
3862
|
+
if (!isNestedOperation) this._endBusyOperation(operationToken);
|
|
3863
|
+
}
|
|
3346
3864
|
}
|
|
3347
3865
|
|
|
3348
3866
|
_clearMaskPlacementMemory() {
|
|
@@ -3352,7 +3870,7 @@ function ensureFabric() {
|
|
|
3352
3870
|
this._lastMaskInitialWidth = null;
|
|
3353
3871
|
}
|
|
3354
3872
|
|
|
3355
|
-
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
3873
|
+
async _restoreStateAfterCropFailure(beforeJson, message, error, options = {}) {
|
|
3356
3874
|
this._reportError(message, error);
|
|
3357
3875
|
|
|
3358
3876
|
if (this._cropRect && this.canvas) this._removeCropRect();
|
|
@@ -3365,7 +3883,7 @@ function ensureFabric() {
|
|
|
3365
3883
|
|
|
3366
3884
|
if (beforeJson) {
|
|
3367
3885
|
try {
|
|
3368
|
-
await this.loadFromState(beforeJson);
|
|
3886
|
+
await this.loadFromState(beforeJson, options);
|
|
3369
3887
|
} catch (restoreError) {
|
|
3370
3888
|
this._reportError('applyCrop: rollback failed', restoreError);
|
|
3371
3889
|
}
|
|
@@ -3391,24 +3909,37 @@ function ensureFabric() {
|
|
|
3391
3909
|
}
|
|
3392
3910
|
|
|
3393
3911
|
_removeCropRect() {
|
|
3394
|
-
if (
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
targetHandlers.handlers.forEach(handlerRecord => {
|
|
3912
|
+
if (this._cropHandlers && this._cropHandlers.length) {
|
|
3913
|
+
this._cropHandlers.forEach(targetHandlers => {
|
|
3914
|
+
(targetHandlers.handlers || []).forEach(handlerRecord => {
|
|
3915
|
+
try {
|
|
3399
3916
|
if (targetHandlers.target && typeof targetHandlers.target.off === 'function') {
|
|
3400
3917
|
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
3401
3918
|
}
|
|
3402
|
-
})
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
this._reportWarning('Crop handler cleanup failed', error);
|
|
3921
|
+
}
|
|
3403
3922
|
});
|
|
3404
|
-
}
|
|
3405
|
-
}
|
|
3923
|
+
});
|
|
3924
|
+
}
|
|
3406
3925
|
|
|
3407
|
-
try { if (this.canvas) this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
3926
|
+
try { if (this.canvas && this._cropRect) this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
3408
3927
|
this._cropRect = null;
|
|
3409
3928
|
this._cropHandlers = [];
|
|
3410
3929
|
}
|
|
3411
3930
|
|
|
3931
|
+
_getCropRectContentBounds(cropRect) {
|
|
3932
|
+
if (!cropRect) return { left: 0, top: 0, width: 1, height: 1 };
|
|
3933
|
+
const width = Math.max(1, (Number(cropRect.width) || 1) * Math.abs(Number(cropRect.scaleX) || 1));
|
|
3934
|
+
const height = Math.max(1, (Number(cropRect.height) || 1) * Math.abs(Number(cropRect.scaleY) || 1));
|
|
3935
|
+
return {
|
|
3936
|
+
left: Number(cropRect.left) || 0,
|
|
3937
|
+
top: Number(cropRect.top) || 0,
|
|
3938
|
+
width,
|
|
3939
|
+
height
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3412
3943
|
/**
|
|
3413
3944
|
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
3414
3945
|
*
|
|
@@ -3439,14 +3970,19 @@ function ensureFabric() {
|
|
|
3439
3970
|
const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
|
|
3440
3971
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
3441
3972
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
3442
|
-
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width
|
|
3443
|
-
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height
|
|
3973
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width));
|
|
3974
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height));
|
|
3444
3975
|
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
3445
3976
|
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
3446
3977
|
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
3447
3978
|
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
3448
3979
|
const width = minCropWidth;
|
|
3449
3980
|
const height = minCropHeight;
|
|
3981
|
+
const requestedCropRotation = !!(this.options.crop && this.options.crop.allowRotationOfCropRect);
|
|
3982
|
+
if (requestedCropRotation && !this._cropRotationWarningEmitted) {
|
|
3983
|
+
this._cropRotationWarningEmitted = true;
|
|
3984
|
+
this._reportWarning('crop.allowRotationOfCropRect is disabled in v1.x because rotated crop export is not supported');
|
|
3985
|
+
}
|
|
3450
3986
|
|
|
3451
3987
|
// Visual style for the temporary crop rectangle.
|
|
3452
3988
|
const cropRect = new fabric.Rect({
|
|
@@ -3458,8 +3994,8 @@ function ensureFabric() {
|
|
|
3458
3994
|
strokeWidth: 1,
|
|
3459
3995
|
strokeUniform: true,
|
|
3460
3996
|
selectable: true,
|
|
3461
|
-
hasRotatingPoint:
|
|
3462
|
-
lockRotation:
|
|
3997
|
+
hasRotatingPoint: false,
|
|
3998
|
+
lockRotation: true,
|
|
3463
3999
|
cornerSize: 8,
|
|
3464
4000
|
objectCaching: false,
|
|
3465
4001
|
originX: 'left',
|
|
@@ -3502,7 +4038,7 @@ function ensureFabric() {
|
|
|
3502
4038
|
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
3503
4039
|
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
3504
4040
|
cropRect.setCoords();
|
|
3505
|
-
const cropBounds =
|
|
4041
|
+
const cropBounds = this._getCropRectContentBounds(cropRect);
|
|
3506
4042
|
const imageLeft = Number(imageBounds.left) || 0;
|
|
3507
4043
|
const imageTop = Number(imageBounds.top) || 0;
|
|
3508
4044
|
const imageRight = imageLeft + (Number(imageBounds.width) || 0);
|
|
@@ -3581,10 +4117,13 @@ function ensureFabric() {
|
|
|
3581
4117
|
async applyCrop() {
|
|
3582
4118
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
3583
4119
|
this._assertIdleForOperation('applyCrop');
|
|
4120
|
+
const operationToken = this._beginBusyOperation('applyCrop');
|
|
4121
|
+
const internalOptions = this._withInternalOperationOptions(operationToken);
|
|
3584
4122
|
|
|
4123
|
+
try {
|
|
3585
4124
|
// Fabric does not update control coordinates automatically after programmatic transforms.
|
|
3586
4125
|
this._cropRect.setCoords();
|
|
3587
|
-
const rectBounds = this._cropRect
|
|
4126
|
+
const rectBounds = this._getCropRectContentBounds(this._cropRect);
|
|
3588
4127
|
|
|
3589
4128
|
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
3590
4129
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
@@ -3595,9 +4134,13 @@ function ensureFabric() {
|
|
|
3595
4134
|
try {
|
|
3596
4135
|
beforeJson = this._serializeCanvasState();
|
|
3597
4136
|
} catch (error) {
|
|
3598
|
-
this.
|
|
4137
|
+
this._reportError('applyCrop: failed to capture rollback state', error);
|
|
3599
4138
|
beforeJson = null;
|
|
3600
4139
|
}
|
|
4140
|
+
if (!beforeJson) {
|
|
4141
|
+
this.cancelCrop();
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
3601
4144
|
|
|
3602
4145
|
const preservedMasks = [];
|
|
3603
4146
|
|
|
@@ -3613,6 +4156,7 @@ function ensureFabric() {
|
|
|
3613
4156
|
maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
|
|
3614
4157
|
maskBounds.top + maskBounds.height > cropRegion.sourceY;
|
|
3615
4158
|
this._removeLabelForMask(mask);
|
|
4159
|
+
this._cleanupMaskEvents(mask);
|
|
3616
4160
|
this.canvas.remove(mask);
|
|
3617
4161
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
3618
4162
|
this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
|
|
@@ -3625,7 +4169,7 @@ function ensureFabric() {
|
|
|
3625
4169
|
this.canvas.renderAll();
|
|
3626
4170
|
}
|
|
3627
4171
|
} catch (error) {
|
|
3628
|
-
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error);
|
|
4172
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to prepare masks', error, internalOptions);
|
|
3629
4173
|
return;
|
|
3630
4174
|
}
|
|
3631
4175
|
|
|
@@ -3646,13 +4190,13 @@ function ensureFabric() {
|
|
|
3646
4190
|
format: 'jpeg'
|
|
3647
4191
|
});
|
|
3648
4192
|
} catch (error) {
|
|
3649
|
-
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
|
|
4193
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error, internalOptions);
|
|
3650
4194
|
return;
|
|
3651
4195
|
}
|
|
3652
4196
|
|
|
3653
4197
|
// Load the cropped image as the new base image.
|
|
3654
4198
|
try {
|
|
3655
|
-
await this.loadImage(croppedBase64, { resetMaskCounter: false });
|
|
4199
|
+
await this.loadImage(croppedBase64, this._withInternalOperationOptions(operationToken, { resetMaskCounter: false }));
|
|
3656
4200
|
if (preservedMasks.length) {
|
|
3657
4201
|
preservedMasks.forEach(mask => {
|
|
3658
4202
|
this._rebindMaskEvents(mask);
|
|
@@ -3665,7 +4209,7 @@ function ensureFabric() {
|
|
|
3665
4209
|
this.canvas.renderAll();
|
|
3666
4210
|
}
|
|
3667
4211
|
} catch (error) {
|
|
3668
|
-
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
|
|
4212
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error, internalOptions);
|
|
3669
4213
|
return;
|
|
3670
4214
|
}
|
|
3671
4215
|
|
|
@@ -3687,6 +4231,9 @@ function ensureFabric() {
|
|
|
3687
4231
|
// Refresh UI state after crop completion.
|
|
3688
4232
|
this._updateUI();
|
|
3689
4233
|
this.canvas.renderAll();
|
|
4234
|
+
} finally {
|
|
4235
|
+
this._endBusyOperation(operationToken);
|
|
4236
|
+
}
|
|
3690
4237
|
}
|
|
3691
4238
|
|
|
3692
4239
|
|
|
@@ -3698,7 +4245,7 @@ function ensureFabric() {
|
|
|
3698
4245
|
* @private
|
|
3699
4246
|
*/
|
|
3700
4247
|
_updateInputs() {
|
|
3701
|
-
const scaleInputElement = this._getElement('
|
|
4248
|
+
const scaleInputElement = this._getElement('scalePercentageInput');
|
|
3702
4249
|
if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
3703
4250
|
}
|
|
3704
4251
|
|
|
@@ -3725,7 +4272,7 @@ function ensureFabric() {
|
|
|
3725
4272
|
for (const key of Object.keys(this.elements || {})) {
|
|
3726
4273
|
const element = this._getElement(key);
|
|
3727
4274
|
if (!element) continue;
|
|
3728
|
-
if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
|
|
4275
|
+
if (key === 'applyCropButton' || key === 'cancelCropButton' || key === 'applyCropBtn' || key === 'cancelCropBtn') {
|
|
3729
4276
|
this._setDisabled(key, false);
|
|
3730
4277
|
} else {
|
|
3731
4278
|
this._setDisabled(key, true);
|
|
@@ -3734,24 +4281,24 @@ function ensureFabric() {
|
|
|
3734
4281
|
return;
|
|
3735
4282
|
}
|
|
3736
4283
|
|
|
3737
|
-
this._setDisabled('
|
|
3738
|
-
this._setDisabled('
|
|
3739
|
-
this._setDisabled('
|
|
3740
|
-
this._setDisabled('
|
|
3741
|
-
this._setDisabled('
|
|
3742
|
-
this._setDisabled('
|
|
3743
|
-
this._setDisabled('
|
|
3744
|
-
this._setDisabled('
|
|
3745
|
-
this._setDisabled('
|
|
3746
|
-
this._setDisabled('
|
|
3747
|
-
this._setDisabled('
|
|
3748
|
-
this._setDisabled('
|
|
3749
|
-
this._setDisabled('
|
|
3750
|
-
this._setDisabled('
|
|
3751
|
-
this._setDisabled('
|
|
3752
|
-
this._setDisabled('
|
|
3753
|
-
this._setDisabled('
|
|
3754
|
-
this._setDisabled('
|
|
4284
|
+
this._setDisabled('zoomInButton', !hasImage || isBusy || this.currentScale >= this.options.maxScale);
|
|
4285
|
+
this._setDisabled('zoomOutButton', !hasImage || isBusy || this.currentScale <= this.options.minScale);
|
|
4286
|
+
this._setDisabled('rotateLeftButton', !hasImage || isBusy);
|
|
4287
|
+
this._setDisabled('rotateRightButton', !hasImage || isBusy);
|
|
4288
|
+
this._setDisabled('createMaskButton', !hasImage || isBusy);
|
|
4289
|
+
this._setDisabled('removeSelectedMaskButton', !hasSelectedMask || isBusy);
|
|
4290
|
+
this._setDisabled('removeAllMasksButton', !hasMasks || isBusy);
|
|
4291
|
+
this._setDisabled('mergeMasksButton', !hasImage || !hasMasks || isBusy);
|
|
4292
|
+
this._setDisabled('downloadImageButton', !hasImage || isBusy);
|
|
4293
|
+
this._setDisabled('resetImageTransformButton', !hasImage || isDefaultTransform || isBusy);
|
|
4294
|
+
this._setDisabled('undoButton', !hasImage || isBusy || !canUndo);
|
|
4295
|
+
this._setDisabled('redoButton', !hasImage || isBusy || !canRedo);
|
|
4296
|
+
this._setDisabled('enterCropModeButton', !hasImage || isBusy);
|
|
4297
|
+
this._setDisabled('applyCropButton', true);
|
|
4298
|
+
this._setDisabled('cancelCropButton', true);
|
|
4299
|
+
this._setDisabled('scalePercentageInput', !hasImage || isBusy);
|
|
4300
|
+
this._setDisabled('rotateLeftDegreesInput', !hasImage || isBusy);
|
|
4301
|
+
this._setDisabled('rotateRightDegreesInput', !hasImage || isBusy);
|
|
3755
4302
|
this._setDisabled('maskList', !hasImage || isBusy);
|
|
3756
4303
|
this._setDisabled('imageInput', isBusy);
|
|
3757
4304
|
this._setDisabled('uploadArea', isBusy);
|
|
@@ -3760,7 +4307,7 @@ function ensureFabric() {
|
|
|
3760
4307
|
/**
|
|
3761
4308
|
* Enables or disables a specific UI element (typically a button) by its key.
|
|
3762
4309
|
*
|
|
3763
|
-
* @param {string} key - Key of the element in this.elements (e.g. '
|
|
4310
|
+
* @param {string} key - Key of the element in this.elements (e.g. 'zoomInButton').
|
|
3764
4311
|
* @param {boolean} disabled - If true, disables the element; otherwise enables.
|
|
3765
4312
|
* @private
|
|
3766
4313
|
*/
|
|
@@ -3895,10 +4442,7 @@ function ensureFabric() {
|
|
|
3895
4442
|
}
|
|
3896
4443
|
} catch (error) { void error; }
|
|
3897
4444
|
|
|
3898
|
-
if (this._cropRect)
|
|
3899
|
-
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
3900
|
-
this._cropRect = null;
|
|
3901
|
-
}
|
|
4445
|
+
if (this._cropRect) this._removeCropRect();
|
|
3902
4446
|
|
|
3903
4447
|
if (this.containerElement && this._containerOriginalOverflow) {
|
|
3904
4448
|
try { this._restoreContainerOverflowState(); } catch (error) { void error; }
|
|
@@ -3918,10 +4462,16 @@ function ensureFabric() {
|
|
|
3918
4462
|
this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
|
|
3919
4463
|
this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
|
|
3920
4464
|
this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
|
|
4465
|
+
this.canvasElement.style.maxWidth = this._canvasElementOriginalStyle.maxWidth;
|
|
3921
4466
|
} catch (error) { void error; }
|
|
3922
4467
|
}
|
|
3923
4468
|
|
|
3924
4469
|
if (this.canvas) {
|
|
4470
|
+
try {
|
|
4471
|
+
this.canvas.getObjects().forEach(object => {
|
|
4472
|
+
if (object && object.maskId) this._cleanupMaskEvents(object);
|
|
4473
|
+
});
|
|
4474
|
+
} catch (error) { void error; }
|
|
3925
4475
|
try { this.canvas.dispose(); } catch (error) { void error; }
|
|
3926
4476
|
this.canvas = null;
|
|
3927
4477
|
this.canvasElement = null;
|
|
@@ -3973,6 +4523,7 @@ function ensureFabric() {
|
|
|
3973
4523
|
|
|
3974
4524
|
/**
|
|
3975
4525
|
* @callback HistoryTaskCallback
|
|
4526
|
+
* @param {Object} [options] - Internal operation options passed by the editor.
|
|
3976
4527
|
* @returns {void|Promise<void>} Result of a history operation.
|
|
3977
4528
|
*/
|
|
3978
4529
|
|
|
@@ -4063,7 +4614,7 @@ function ensureFabric() {
|
|
|
4063
4614
|
task.reject(error);
|
|
4064
4615
|
}
|
|
4065
4616
|
} finally {
|
|
4066
|
-
if (
|
|
4617
|
+
if (this.currentTask === task) this.currentTask = null;
|
|
4067
4618
|
}
|
|
4068
4619
|
}
|
|
4069
4620
|
} finally {
|
|
@@ -4194,11 +4745,11 @@ function ensureFabric() {
|
|
|
4194
4745
|
*
|
|
4195
4746
|
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
4196
4747
|
*/
|
|
4197
|
-
undo() {
|
|
4748
|
+
undo(options = {}) {
|
|
4198
4749
|
return this.enqueue(async () => {
|
|
4199
4750
|
if (this.currentIndex >= 0) {
|
|
4200
4751
|
const index = this.currentIndex;
|
|
4201
|
-
await this.history[index].undo();
|
|
4752
|
+
await this.history[index].undo(options);
|
|
4202
4753
|
this.currentIndex = index - 1;
|
|
4203
4754
|
}
|
|
4204
4755
|
});
|
|
@@ -4209,11 +4760,11 @@ function ensureFabric() {
|
|
|
4209
4760
|
*
|
|
4210
4761
|
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
4211
4762
|
*/
|
|
4212
|
-
redo() {
|
|
4763
|
+
redo(options = {}) {
|
|
4213
4764
|
return this.enqueue(async () => {
|
|
4214
4765
|
if (this.currentIndex < this.history.length - 1) {
|
|
4215
4766
|
const index = this.currentIndex + 1;
|
|
4216
|
-
await this.history[index].execute();
|
|
4767
|
+
await this.history[index].execute(options);
|
|
4217
4768
|
this.currentIndex = index;
|
|
4218
4769
|
}
|
|
4219
4770
|
});
|